Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Services: The Contract Is the Code

Every client-server app has an API contract. The question is where it lives. In most stacks it lives in three places at once: the backend’s route handlers, the frontend’s fetch calls, and (if the team is diligent) a schema document that was accurate in March. Each copy can drift, and the drift is invisible until runtime.

Mar’s answer: the contract is one value, in one module, imported by both sides.

Declaring a contract

-- Shared.mar
type alias Task = { id : Int, name : String, done : Bool }

type AddTaskOutcome
    = Added Task
    | NameEmpty


listTasks : Service () (List Task)
listTasks = Service.declare GET "/tasks"

getTask : Service { id : Int } (Maybe Task)
getTask = Service.declare GET "/tasks/{id:Int}"

addTask : Service { name : String } AddTaskOutcome
addTask = Service.declare POST "/tasks"

A Service req resp is a typed promise: send req, receive resp. Service.declare pins the HTTP verb and path; the type annotation pins the payload types. That is the entire API layer. No OpenAPI file, no generated client, no serializers: Mar derives the wire encoding from the types (path params fill their {name:Type} slots; the rest rides the query string for GET/DELETE and the JSON body for POST/PUT/PATCH).

Implementing it

The backend pairs each contract with a handler whose type the contract dictates:

-- Backend/Tasks.mar
addTaskImpl : { name : String } -> Shared.User -> Task Shared.AddTaskOutcome
addTaskImpl input user =
    if String.trim input.name == "" then
        Task.succeed Shared.NameEmpty
    else
        Repo.create tasks
            { name = input.name, done = False, userId = user.id }
            |> Task.map Shared.Added


services =
    [ Auth.protect Shared.listTasks listTasksImpl
    , Auth.protect Shared.addTask addTaskImpl
    ]

A handler is req -> Task resp (Auth.protect inserts the signed-in User as a second argument; the next chapters cover Task and auth). The part to appreciate: the handler cannot return anything but the declared response type. There is no way to sneak an undocumented field or a surprise shape into the API, because the API is a type and the handler is checked against it.

Calling it

-- Frontend page, in update
AddClicked ->
    ( { model | submitting = True }
    , Service.call Shared.addTask { name = model.draft } Added
    )

Added (Ok (Shared.Added task)) -> ...
Added (Ok Shared.NameEmpty)    -> ...
Added (Err why)                -> ...

Service.call takes the contract, a request that must typecheck against it, and the message to deliver the result. HTTP, encoding, decoding: invisible. The page speaks in domain types.

What the compiler now guarantees

Because both halves import the same Shared.addTask:

  • Rename the service, change a field, or alter the outcome type, and every call site and the handler fail to compile until updated. API drift is not detected; it is impossible.
  • The frontend’s case over AddTaskOutcome is exhaustiveness-checked. Add a TeamFull outcome next month and every page that calls addTask becomes a todo list of compile errors, which is exactly what you want.
  • Requests are well-formed by construction. There is no way to forget a required field in the payload; the record type will not have it.

One more check, unusual and telling: a GET service whose handler writes is a compile error. Effects are visible in types (that was Part I’s promise), so the compiler can see a Repo.update reached from a GET handler and reject it. HTTP semantics, enforced statically.

The philosophical point

Notice what did not happen here. There is no code generation step, no shared-types package to version and publish, no JSON schema to keep honest. Those are all tools for coordinating two programs written in two languages. Mar dissolves the problem instead of managing it: there is one program, and the “API” is just its module boundary.

This is the sashimono idea at full strength. The joint holds because the two pieces are shaped against each other by the same tool, not because glue was applied afterward.

Next: where the data those handlers touch actually lives.