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:
- What your system does (domain descriptions)
- 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.