Coming from Spring MVC
The mapping
Section titled “The mapping”Arc replaces the controller layer, not Spring itself. Dependency injection, @Configuration,
@Bean, and every other ordinary Spring Boot mechanism still work exactly as before — the
difference is what you write to expose a command or a query over HTTP.
| Spring MVC | Arc |
|---|---|
@RestController class with a @PostMapping method | @Command class with a public handle method |
@RestController class with a @GetMapping method | @ReadModel class with a static or @JvmStatic companion query method |
@RequestBody CreateTaskRequest body | The command’s own constructor properties — the command is the request body |
@RequestParam / @PathVariable | Unannotated query method parameters |
@Autowired field or constructor injection into the controller | Unannotated handle parameters resolved from Spring by type; a query method needs @FromServices on each service parameter, because an unannotated query parameter is a client argument |
@Valid @RequestBody X body, BindingResult result | Automatic Jakarta validation on the command or query-argument graph when a Validator bean exists — see Add validation |
ResponseEntity<T> | A CommandResult<T> or QueryResult<T> JSON envelope — see the HTTP contract reference |
@PreAuthorize("hasRole('ADMIN')") | @Authorize(roles = ["admin"]) or @Roles("admin") on the class or the operation — see Protect the command |
| A hand-written OpenAPI annotation set, or springdoc | Arc generates OpenAPI 3.1 from the same metadata — see Publish an OpenAPI document |
A hand-written or generated fetch/axios TypeScript client | A generated, strict-mode TypeScript client — see Generate TypeScript proxies |
SseEmitter or a WebSocketHandler for live data | A query returning Flow<T> or Flow.Publisher<T> — Arc hosts HTTP snapshots, SSE, and WebSocket for it automatically — see Declare observable queries |
A command, side by side
Section titled “A command, side by side”// Spring MVC@RestControllerpublic class TaskController { private final TaskRepository repository;
public TaskController(TaskRepository repository) { this.repository = repository; }
@PostMapping("/api/create-task") public ResponseEntity<TaskCreated> create(@Valid @RequestBody CreateTaskRequest request) { var task = repository.create(request.title()); return ResponseEntity.ok(new TaskCreated(task.id(), task.title())); }}// Spring MVC@RestControllerclass TaskController(private val repository: TaskRepository) { @PostMapping("/api/create-task") fun create(@Valid @RequestBody request: CreateTaskRequest): ResponseEntity<TaskCreated> { val task = repository.create(request.title) return ResponseEntity.ok(TaskCreated(task.id, task.title)) }}// Arc@Command@AllowAnonymouspublic record CreateTask(String title) { public TaskCreated handle(TaskRepository repository) { var task = repository.create(title); return new TaskCreated(task.id(), task.title()); }}// Arc@Command@AllowAnonymousdata class CreateTask(val title: String) { fun handle(repository: TaskRepository): TaskCreated { val task = repository.create(title) return TaskCreated(task.id, task.title) }}There is no TaskController and no separate CreateTaskRequest DTO. CreateTask is the request
body, repository is resolved from Spring the same way constructor injection would resolve it, and
the route (POST /api/create-task by convention) is derived and generated by KSP rather than
declared with @PostMapping.
A query, side by side
Section titled “A query, side by side”// Spring MVC@RestControllerpublic class TaskQueryController { private final TaskRepository repository;
public TaskQueryController(TaskRepository repository) { this.repository = repository; }
@GetMapping("/api/tasks") public List<TaskView> all() { return repository.all(); }}// Spring MVC@RestControllerclass TaskQueryController(private val repository: TaskRepository) { @GetMapping("/api/tasks") fun all(): List<TaskView> = repository.all()}// Arc@ReadModel@AllowAnonymouspublic record TaskView(String id, String title) { @Path("/api/tasks") public static List<TaskView> all(@FromServices TaskRepository repository) { return repository.all(); }}// Arc@ReadModel@AllowAnonymousdata class TaskView(val id: String, val title: String) { companion object { @JvmStatic @Path("/api/tasks") fun all(@FromServices repository: TaskRepository): List<TaskView> = repository.all() }}@FromServices exists precisely to distinguish repository — a dependency — from an unannotated
parameter, which a caller supplies as a query argument. Without a query taking caller arguments,
every parameter here happens to be a dependency; see Add a query to a read model
for a query that takes both.
What does not change
Section titled “What does not change”- Spring Security,
@ConfigurationProperties,@Scheduled,@Transactionaloutside a command handler, and every other Spring Boot mechanism keep working exactly as they do today. - You can still write ordinary
@RestControllerendpoints alongside Arc’s generated ones in the same application — Arc adds routes, it does not remove Spring MVC. - Spring Data JPA and MongoDB repositories are injected the same way; see Use Spring Data read models for the paging, sorting, and read-model resolution Arc adds on top of them.
Where to go next
Section titled “Where to go next”- Build your first Arc application in Kotlin or in Java.
- Create and validate commands and Expose one-shot and observable queries for the complete contract.
- Why Arc for Kotlin and Java for the problem this all removes.