DEV Community

Vincent Tran
Vincent Tran

Posted on Originally published at 0xgosu.dev on

Acadia: Bringing Precise Types and Functional Programs to the Database

Database-backed applications usually contain several descriptions of the same fact.

A customer status may begin as a constrained value in a product model, become a TEXT column in SQLite, travel through a server record, cross an HTTP boundary as JSON, and finally arrive as a union type in a browser application. Each layer can be reasonable on its own while the complete chain remains fragile. Rename a case, change its nullability, or add a field, and the compiler only sees the pieces that belong to its language.

Acadia is an early attempt to make that chain one program. Created by Elm designer Evan Czaplicki, it combines an Elm-like functional language with database tables, transactions, generated SQL, and client/server integrations. Instead of treating SQL as a string embedded in an application, Acadia treats tables and endpoints as typed source that a compiler can inspect together.

The public alpha is deliberately narrow, but the design asks a broad question: what would database programming look like if schema changes, queries, transactions, and network contracts received the same compiler attention as ordinary application code?

The real problem is contract drift

Teams often describe their database problem as an ORM problem or a SQL ergonomics problem. Those labels point at real frustrations, but they miss the larger system.

The difficult boundary is not only between objects and rows. It is between several independently evolving contracts:

  • the domain types developers want to express;
  • the physical representation accepted by the database;
  • the queries and updates that operate on that representation;
  • the server functions that expose those operations;
  • the wire format sent over the network; and
  • the client types that consume the response.

Conventional tooling checks some edges. A typed query builder can confirm that a selected column exists. Generated API clients can synchronize an HTTP schema. Migration tools can order SQL files. Runtime validators can reject malformed payloads.

What they rarely provide is one place where changing a table field produces a useful error at every dependent endpoint and client call. The result is defensive translation: database rows become server models, server models become transport objects, and transport objects become client models. Every conversion is a chance to lose information.

Acadia’s central bet is that these are not separate contracts. They are different views of one typed program.

“A
One compiler sees the data model and endpoint program, then derives the database and application-facing contracts together.

A table is a precise program value

An Acadia table begins with a record type rather than a CREATE TABLE string. A simplified inventory model might look like this:

type alias PantryItem =
  { id : PantryItemId
  , label : String
  , state : ItemState
  }

type PantryItemId = PantryItemId UInt64

type ItemState
  = Available
  | Reserved
  | Used

Enter fullscreen mode Exit fullscreen mode

The wrapper around UInt64 matters. An item identifier and a customer identifier may share a physical representation, but they should not be interchangeable in application code. The custom ItemState type also carries more information than an unconstrained string.

The table declaration then connects the row type to database behavior:

pantry : Table Security.Unrestricted PantryItem
pantry :=
  Table.table
    { primary = .id
    , security = Security.unrestricted
    , indexes = []
    , constraints = []
    }

Enter fullscreen mode Exit fullscreen mode

The same declaration identifies the primary key, indexes, constraints, and row-level security policy. The goal is not to make database design disappear. It is to turn that design into compiler-visible data rather than parallel configuration.

This is an important distinction from a typical active-record ORM. Acadia does not begin with mutable objects and try to preserve their identity inside a relational store. Its surface is closer to relational transformations expressed with functional operators. Rows remain rows; collections are filtered, mapped, joined, and selected.

Endpoints are compiled, not interpreted

A read endpoint can be assembled with familiar operations:

findLabel : Cookies -> PantryItemId -> Transaction String
findLabel _ wantedId =
  access pantry Security.Unrestricted
    |> filter (\item -> item.id == wantedId)
    |> map .label
    |> select

Enter fullscreen mode Exit fullscreen mode

The compiler can lower that expression to parameterized SQL equivalent to:

SELECT item.label
FROM pantry AS item
WHERE item.id = $1;

Enter fullscreen mode Exit fullscreen mode

That compilation boundary is the key. If a library loads every row and then applies filter in application memory, the pleasant syntax hides a serious performance bug. If it translates the whole expression at build time, the database still performs the set operation and its optimizer still chooses the execution plan.

Acadia prints the SQL it generates, which is essential for evaluating a young compiler. A typed abstraction should not ask for blind trust. Developers need to inspect joins, predicates, indexes, query plans, and row counts just as they would with hand-written SQL.

Compile-time generation also opens the door to optimizations across a larger expression. The compiler can see the complete endpoint rather than receiving a sequence of opaque runtime calls. That is how a functional interface can avoid degenerating into an N+1 query machine: the program must have a clear relational meaning before it runs.

Transactions become ordinary composition

Database work becomes awkward when later statements depend on values created by earlier statements. In raw SQL, developers may use common table expressions, RETURNING, stored-procedure variables, or several client round trips. In application code, the same workflow can accidentally hold a transaction open across unrelated work.

Acadia represents database effects with a Transaction type and offers a binding syntax similar in spirit to async/await. Consider starting a password-reset session:

beginReset : Cookies -> Email -> Transaction ResetSecret
beginReset _ email =
  let
    secret := Uuid.generate ResetSecret
    created := Time.now

    () :=
      insert resetSessions Security.Unrestricted
        { email = email
        , secret = secret
        , created = created
        }
  in
  Transaction.succeed secret

Enter fullscreen mode Exit fullscreen mode

Each bound value can feed later steps, but the complete block describes one transaction. Either every database effect succeeds and commits, or the operation fails without exposing a partially written state.

SQLite’s own rules still matter. Every read or write occurs inside a transaction, and SQLite permits many concurrent readers but only one simultaneous writer. A language can make composition safer without removing locking, contention, busy errors, or the need to keep transactions short. The SQLite transaction documentation remains part of the operating model.

The useful shift is that atomicity becomes visible in the endpoint’s type and structure. Reviewers can see which effects belong together, and the compiler can generate a single database program rather than coordinating an accidental series of network round trips.

Generated types close the client-server gap

Acadia currently advertises Elm and Haskell integration, with more host languages planned according to demand during the alpha. Once an endpoint’s input and output types are known, the compiler can derive the code that calls it from the server or client.

This removes a familiar category of glue:

  • manually maintained request and response records;
  • JSON encoders and decoders that mirror those records;
  • route names duplicated in several languages;
  • nullable fields added on one side but not the other; and
  • runtime failures caused by an old caller expecting a previous shape.

End-to-end generation is most valuable when it improves failure timing. A changed column should invalidate the endpoint that reads it. A changed endpoint should invalidate the generated binding. A client using the old result type should then fail to compile with an error at the call site.

That is a stronger promise than “less boilerplate.” Boilerplate can be generated by many tools. The deeper benefit is a dependency graph the compiler can follow from storage to interface.

Migrations should be programs the compiler can question

SQL migration anxiety comes from applying a textual command to state that may not match the developer’s assumptions. A staging database can drift from production. Old application versions may still be serving traffic. A conversion that is valid for the schema can be invalid for the actual data.

Acadia’s stated goal is verified migration: because the compiler knows the existing and desired column types, it can check the proposed transition before it reaches a live database. This does not make every migration automatically safe. Lock duration, table size, backfills, concurrent deploys, and database-specific behavior remain operational concerns.

It does create a better workflow. The compiler can reject a type-incoherent plan early, emit a concrete migration for review when the transition is representable, and keep old callers in view while the contract changes.

“A
Compiler verification is an early gate, not permission to skip staging, backups, lock analysis, or rollout planning.

A production-minded migration still needs several checks:

  1. Confirm that the generated plan matches the intended data transformation.
  2. Test it against a realistic copy of the current schema and data volume.
  3. Measure locks, write amplification, and runtime.
  4. Verify compatibility with every application version that may overlap the deploy.
  5. Prepare a backup, rollback, or forward-fix path appropriate to the operation.
  6. Observe the migration and the first application traffic after it completes.

Static verification narrows the failure surface. It does not repeal physics or deployment concurrency.

Why compile to the database at all?

The project began with a server-rendering problem. While experimenting with Elm on the server in 2017, Czaplicki ran into a mismatch: the application “knew” data should exist, but the database boundary could not guarantee that knowledge in the same type system. By 2019, feedback from Elm teams pointed repeatedly to backend integration rather than frontend language design as the larger source of engineering pain.

In 2020, the exploration moved toward stored procedures as a compilation target. The first design resembled SQL with a stronger type system. A simpler idea changed the direction: database programs could look like Elm, using operators such as map and filter, while the compiler handled the relational translation.

That history explains several Acadia choices. It is not merely a prettier query builder. It is trying to make database code participate in the same language-design values that made Elm distinctive: precise types, constrained effects, deliberate evolution, and error messages written for humans.

The work also took years because the hard questions are semantic, not cosmetic. Can a functional program without general recursion always lower to a bounded relational query? Can the compiler prevent N+1 behavior? How should custom types survive storage? Can migrations remain compatible with old clients? Which guarantees transfer cleanly between SQLite and PostgreSQL?

The public alpha represents a working answer to a subset of those questions, not the end of the research program.

Where the abstraction ends

The Hacker News discussion around the release concentrated on the right risks.

Database feature coverage can lag. Mature databases expose partitioning, specialized indexes, compression, triggers, extensions, custom aggregates, window functions, and vendor-specific controls. A new language cannot cover all of that immediately. Acadia explicitly notes that window functions and custom aggregate functions did not make the initial release. It allows dropping down to SQL, but every escape hatch is also a boundary where some static guarantees may weaken.

Stored representations affect interoperability. Precise custom types need a physical encoding. If that encoding is convenient only through generated Acadia bindings, another service may find the database harder to read directly. Teams should inspect how each type is stored, document the format, and test access from every language that must coexist.

The database may outlive the application language. Data often survives several server rewrites. Before adopting any schema-owning compiler, ask whether the resulting tables, constraints, migration history, and procedures remain understandable without that compiler.

Alpha tooling changes quickly. The current release is intentionally minimal. Advanced queries, integrations, diagnostics, and operational workflows will evolve. A prototype can tolerate that movement; a business-critical system needs a clear upgrade and support plan.

Licensing and source availability matter. Several commenters raised concerns about depending on a young database tool under its current commercial and distribution model. Evaluate the actual license terms, offline build story, long-term maintenance options, and exit path before committing durable data to it.

These are not reasons to dismiss the design. They are the acceptance criteria for any tool that wants to sit between an application and its most durable state.

A sensible evaluation plan

Do not begin with the most important production database. Choose a small vertical slice whose types are currently painful and whose SQL is easy to verify.

A good experiment might include:

  • one table with a wrapped identifier and a custom sum type;
  • one filtered read endpoint;
  • one multi-step write transaction;
  • one schema change that forces a migration;
  • one generated server integration;
  • one generated client call; and
  • one advanced query that requires an SQL escape hatch.

Then evaluate the whole lifecycle, not only the first successful demo.

Inspect the generated schema and SQL. Compare query plans and latency with a hand-written implementation. Introduce type errors deliberately and judge the diagnostics. Change a column while an old client still exists. Restore the database without Acadia. Read the stored custom types from a separate program. Exercise concurrent writes and failure paths. Finally, remove Acadia from the prototype and estimate the exit cost.

The last test is especially valuable. A durable abstraction should make the common path better without making departure catastrophic.

The larger idea deserves attention

SQL engines are extraordinary pieces of software. The case for Acadia does not require pretending otherwise. The question is whether SQL strings, migration files, transport schemas, and client decoders should remain separate islands when a compiler could reason across them.

Acadia’s answer is to keep the relational engine and replace much of the fragmented programming interface around it. Tables become precise types. Queries become functional expressions compiled to SQL. Dependent effects become one typed transaction. Server and client bindings come from the same endpoint definition. Migration errors move earlier, when they are cheaper to fix.

The alpha is not yet a universal database platform, and its sharpest promises still need production evidence. But it demonstrates a coherent alternative to both raw string composition and object-first ORMs: preserve the database, preserve relational execution, and give the surrounding program a type system wide enough to see the whole contract.

That direction is worth exploring even for teams that never adopt Acadia. We should expect database tools to show generated SQL, track dependencies across layers, verify migrations against known types, preserve interoperability, and produce errors that help developers recover. Once those expectations become normal, today’s duplicated contracts will look less like an unavoidable cost and more like an interface we simply stopped questioning.

The original Acadia announcement explains the language and its development history. The Acadia documentation and examples cover the public alpha, while the Hacker News discussion captures the debate about SQL coverage, interoperability, licensing, and database ownership.

Top comments (0)