Declarative programming is like ordering food at a restaurant, rather than going into the kitchen and cooking it yourself

Declarative Programming: Understanding the what, not the how

Mike Buhot profile picture

Mike Buhot

12 May 2025 · 5 min read

As software systems grow more complex, the way we approach building them becomes increasingly important. Enter declarative programming — an approach that's transforming how we build software by focusing on describing what a program should accomplish rather than precisely how it should do it.

What is declarative programming?

Declarative programming is a style where you express the logic of your application without describing its control flow. Instead of providing step-by-step instructions for how to achieve a result, you simply state what result you want.

Think of it like ordering food at a restaurant. With an imperative approach, you'd go into the kitchen and cook the meal yourself, following each step in the recipe. With a declarative approach, you simply tell the waiter what you want, and the kitchen handles the details of preparation.

Many familiar technologies are declarative:

  • HTML: You declare what elements should appear on a page, not how the browser should render them
  • CSS: You specify how elements should look, not the rendering process
  • SQL: You describe what data you want, not how the database engine should retrieve it

How declarative programming differs from imperative

Imperative programming, the traditional approach, involves writing explicit instructions for the computer to follow. You control every step of the process, detailing how to manipulate state and achieve the desired outcome.

Let's examine this distinction through examples. First, an imperative approach to creating a post:

def create_post(attrs, user) do
  # Authorization check
  if user.role != "intern" do
    # Logging start time
    start_time = Time.utc_now()

    try do
      with {:ok, post} <- Posts.create(attrs)
           # Potentially more operations
      do
        # Audit logging
        AuditLog.create(%{
          user_id: user.id,
          action: "create",
          entity: "post",
          result: "success"
        })

        # Performance logging
        Logger.info("Created post in #{Time.diff(Time.utc_now(), start_time, :millisecond)}ms")

        {:ok, post}
      else
        err ->
          # Error handling
          Logger.error("Failed to create post: #{inspect(err)}")
          AuditLog.create(%{
            user_id: user.id,
            action: "create",
            entity: "post",
            result: "error",
            details: inspect(err)
          })
          err
      end
    rescue
      e ->
        # Exception handling
        Logger.error("Exception when creating post: #{inspect(e)}")
        {:error, e}
    end
  else
    {:error, :unauthorized}
  end
end

Compare this with the declarative approach:

The difference is striking. The declarative approach is easier to read and maintain. The code cleanly separates the what (the operation descriptions) from the how (the engine that processes these descriptions).

def create_post(attrs, user) do
  %Operation{
    resource: MyApp.Blog.Post,
    action: :create,
    params: attrs,
    actor: user,
    allowed_roles: ["admin", "user", "intern"],
  }
  |> Engine.execute()
end

The Engine module would be completely generic and provide the execute function, ensuring all operations have consistent logging, auditing, authorisation, and performance metrics. Importantly, this code only needs to be defined once in the entire application.

defmodule Engine do
    def execute(%Operation{} = operation) do
      operation
      |> record_start_time()
      |> authorize()
      |> run_action()
      |> log_performance()
      |> log_audit()
      |> log_errors()
      |> extract_result()
    end

    defp record_start_time(operation), 
      do: %{operation | start_time: DateTime.utc_now()}

  # ... other private functions for cross-cutting behaviour ...
end

The origins and rise of declarative programming

Declarative programming isn't new—its roots stretch back to early languages like SQL (1970s) and Prolog. What's changed is how these concepts are now being applied across software development.

This rise stems from increasing complexity in software systems. As applications grow, managing the details of how everything works becomes overwhelming. Declarative approaches help us manage this complexity by creating clear separations between intent and implementation.

While 'premature abstraction' can be a serious problem, 'late abstraction' can be just as serious. Declarative Design is the method by which you side-step the problems presented by imperative styles of abstraction.

Zach Daniel, creator of Ash Framework

Declarative design in Ash Framework

Ash Framework sits at the intersection of declarative programming and domain-driven design. It provides a structured way to define your domain entities and their relationships, the actions they can perform, and the rules governing them—all declaratively.

This approach creates a clear separation between:

  1. What your system does (domain descriptions)
  2. How it does it (the engine that processes these descriptions)

In Ash Framework, you describe your domain using Elixir structures that feel like configuration rather than code. These descriptions become a domain-specific language (DSL) that both developers and non-technical stakeholders can understand.

Why declarative programming makes Ash powerful

Adopting Ash Framework's declarative approach offers significant benefits:

  • Improved readability and maintainability - The clean separation between declaration and execution makes code more readable. Your domain descriptions serve as documentation that stays in sync with the code because they are the code.
  • Consistent behaviour across your application - By centralising how operations work in the engine while keeping descriptions separate, you ensure operations behave consistently across your application. Adding authentication, logging, or performance monitoring happens in one place rather than across dozens of functions.
  • Easier to extend and modify - Adding new operations becomes trivial—just add a new declaration without touching the engine. Changing how operations work happens in one place.
  • Powerful abstractions without complexity - Ash creates powerful abstractions where the complexity is contained within the framework, rather than in your own application code. These abstractions provide leverage without the maintenance burden typical of traditional abstractions.
  • Domain-focused development - Perhaps most importantly, declarative programming in Ash shifts focus from implementation details to domain understanding. This leads to software that better aligns with business needs and adapts more readily as those needs evolve.

Getting started with declarative programming

You don't have to adopt Ash Framework to benefit from declarative programming. Start small by identifying areas of your codebase where you're repeating similar patterns with slight variations—these are prime candidates for a declarative approach.

Declarative Design can work at many levels across your system. It can be the driver behind complex system flows, and it can also help with abstracting and encapsulating code on a smaller scale.

Zach Daniel, creator of Ash Framework

Try extracting configuration from code, separating the what from the how. Even small steps toward declarative design can yield substantial benefits.

Other examples from the Elixir ecosystem are:

Phoenix router pipelines declare the list of transformations a Plug.Conn will pass through before your Controller handles the request.

  # router.ex
  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {MyApp.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

The PhoenixTest API provides a high level API to specify what should be on the page and what should be entered into form fields without complex CSS selectors or lower level APIs to simulate a form submit.

conn
|> visit(~p"/blogs/create")
|> assert_has("h1", text: "Create a Post")
|> fill_in("Title", with: "Today I Learned: Declarative Programming")
|> fill_in("Body", with: "It's great!")
|> submit()
|> assert_has("h1", text: "Today I Learned")

The Ecto.Repo Query API that accepts an Ecto.Queryable data structure, separating the execution (through the database) from the declaration of what needs to be retrieved.

# Fetch all post titles
query = 
  from(p in Post, 
    where: p.inserted_at > ^~D[2025-01-01], 
    select: p.title
  )

MyApp.Repo.all(query)

The future is declarative

As software continues to grow in complexity, declarative approaches will become increasingly important. They help us manage complexity by creating clear separations of concerns, focusing on domain problems rather than implementation details.

Whether you adopt Ash Framework or simply incorporate declarative principles into your existing codebase, you'll likely find that declarative programming offers a powerful path toward more maintainable, understandable, and adaptable software.

Mike Buhot profile picture

WRITTEN BY

Mike Buhot

Hi, I’m Mike! I'm a functional programming enthusiast. When I'm not building awesome software I'm probably enjoying the outdoors with my kids or trying a new low-and-slow barbeque recipe.

Want to read more?

The latest news, articles, and resources, sent to your inbox occasionally.