vernac / architecture, expressed.
Browse documentation

Put your domain behind an HTTP API

A web request is not a domain object. An inbound adapter makes that boundary visible: read transport data, construct validated domain values, call a usecase and map its answer. Vernac generates the Spring MVC plumbing while the application contract stays in your usecase.

This exercise does not persist tasks. Start with the basic Maven template, which already configures Vernac generation and the Spring Boot dependency versions. The usecases still require a transaction manager; below we supply one through Spring JDBC and a disposable in-memory H2 datasource. No Docker or external database is needed. Use a separate namespace from any earlier Task exercise. Save this as src/main/vernac/example/tasks/api.vernac:

namespace example.tasks;

id TaskId;
value Title(String) validates {
    require(!self.string().isBlank(), "Title must not be blank");
};
usecase CreateTask(Title) returns TaskId behavior {
    execute { return TaskId.create(); }
}
usecase CompleteTask(TaskId) behavior {
    execute { System.out.println("Completed " + taskId.asString()); }
}

adapter TaskApi inbound http uses CreateTask createTask, CompleteTask completeTask {
    base "/api/tasks";
    create {
        POST "/";
        body CreateTaskRequest;
        execute createTask { title <- body.title; }
        respond 201 CreatedTaskResponse using toResponse;
    }
    complete {
        POST "/{taskId}/complete";
        path UUID taskId;
        execute completeTask { taskId <- path.taskId; }
        respond 204;
    }
    schema CreateTaskRequest(String title);
    schema CreatedTaskResponse(UUID taskId);
    mapping toResponse: CreatedTaskResponse <- TaskId { taskId <- source; }
}

The tiny usecases demonstrate transport wiring; they do not store a task. Add the existing aggregate/repository patterns when building a persistent application. The Dispatch showcase uses generated create/release endpoints against its actual persistence usecases.

Wire the application

Add these dependencies to the template’s existing <dependencies> (its Spring Boot parent manages all except the Vernac version):

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>org.vernac</groupId>
    <artifactId>vernac-runtime-http</artifactId>
    <version>${vernac.version}</version>
</dependency>

Add org.springframework.boot:spring-boot-maven-plugin to <build><plugins>. Create src/main/java/example/tasks/TaskApplication.java:

package example.tasks;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class TaskApplication {
    public static void main(String[] args) {
        SpringApplication.run(TaskApplication.class, args);
    }
}

Its package makes component scanning include the generated controller and usecases. With H2 and Spring JDBC present, Boot supplies the datasource and transaction manager. This is transaction infrastructure for the exercise, not a generated Task repository. For a real persistent application, follow the PostgreSQL tutorial.

Call the generated endpoints

From the application directory, run mvn clean spring-boot:run. In another terminal:

$created = Invoke-RestMethod -Method Post -Uri 'http://localhost:8080/api/tasks' `
    -ContentType 'application/json' -Body '{"title":"Try the generated API"}'
$created.taskId
Invoke-WebRequest -Method Post -Uri "http://localhost:8080/api/tasks/$($created.taskId)/complete"

The first response is 201 with {"taskId":"..."}. The completion response is 204 with no body, and the application logs the ID. The title is wrapped through Title.of; the returned ID is unwrapped into the response UUID field. Neither the controller nor the mapping starts a transaction: the usecase already owns it.

HTTP input, usecase transaction and response mapping

Explore failures and results

Try a blank title (422), a malformed UUID (400) and an additional JSON property (accepted). Missing required JSON fields produce 400. Responses contain a stable code and safe public message; your internal exception text stays out of the body.

For an operation declared returns (TaskId, Title), map from CreateTask.Result and assign DTO fields from source.taskId/source.title. This is the usecase’s explicit Result, not another response invented by the adapter. Optional single results require an explicit absence response such as on absent respond 404;.

Read the HTTP inbound contract for wire formats, query/header declarations, error customization and the deliberately limited scope. Business orchestration belongs in the usecase; this adapter remains a transport/ACL boundary. Configure Spring Security normally when exposing a protected application.

Return independent pages

A GET can return multiple materialized page components together with ordinary values. Use declared offset, counted offset or cursor usecase results and corresponding page fields in a named local DTO. The entry mapping keeps metadata intact, and JSON serialization performs no repository work. The board tutorial shows the complete model, separate query page numbers, cursor continuation and safe 400/INVALID_CURSOR handling.

Give failures a public meaning

When a usecase rejects a request, the API should explain the failure without exposing internal exception messages. Put shared rules at adapter scope, and specialize a particular endpoint only when its public contract differs. Add this clause last in the adapter from this tutorial:

errors {
    when validation then respond 422 "INVALID_TASK" "Check the task values.";
    when not found then respond 404;
    otherwise default;
}

An empty title now produces HTTP 422 with code: INVALID_TASK and the explicit public message. The validator’s internal message is not published. A missing aggregate still uses the safe NOT_FOUND reply. Other errors retain their defaults.

The Dispatch example demonstrates this on its create endpoint: POST an empty title to /api/tours to receive INVALID_TOUR, or omit a required JSON field to receive the adapter-level INVALID_REQUEST. Neither request invokes a successful usecase. See the full selector and precedence contract.