Skip to content

Vertical slices

We recommend organizing an Arc application by feature, not by technical layer. A feature, “show the ticker” or “register an author”, is one behavior that spans the stack: a command or query on the server, the proxy the browser calls it with, and the page that shows it. Arc does not require this; it finds your commands and read models wherever they live. This page shows how to make the layout cohesive.

This is the same advice as Arc on .NET, and the layout is the same: open one folder and see the whole feature.

The showcase sample’s ticker is a slice:

lib/samples/ticker/
├── ticker.ex # the read model
├── queries.ex # the observable query
├── clock.ex # the process that makes it tick
├── Ticker.ts # generated: the read model proxy
├── Observe.ts # generated: the query proxy
├── index.ts # generated: re-exports
└── TickerPage.tsx # the React page, written by hand

The page imports the proxy that sits beside it:

Samples/showcase/lib/samples/ticker/TickerPage.tsx
import { Observe } from '.';

When the query’s parameters or the read model change, the proxy next to it changes on the next compile and the page stops compiling where it no longer fits.

Generate the proxies beside the Elixir they come from with layout: :colocated. The output is then the root that holds both your Elixir and your React code:

Samples/showcase/mix.exs
compilers: Mix.compilers() ++ [:arc],
arc: [application: Samples.Arc, output: "lib", layout: :colocated],

Each proxy goes in the folder of the .ex file that defines its module, relative to output. Two modules in one file share a folder. A module whose source file is outside output (from a dependency, say) is generated where the default layout would put it: under output, in a folder that mirrors its namespace. Imports between proxies are relative paths, written for you.

mix arc.gen.proxies --layout colocated does the same from the command line.

Elixir compiles .ex files only, so a .ts or .tsx file in lib is ignored by Mix and by Phoenix’s code reloader. The JavaScript tooling needs two things:

  • Put package.json and node_modules at the project root, above lib. A file in lib/samples/ticker finds @cratis/arc by looking upward from its own folder, so node_modules must be an ancestor of every slice.
  • Point the TypeScript configuration and the bundler at both the app shell and lib:
Samples/showcase/tsconfig.json
"include": ["frontend/src", "lib/**/*.ts", "lib/**/*.tsx", "vite.config.ts"]

The app shell, the index.html, the providers and the page that picks which slice to show, can stay in its own folder (frontend in the showcase). It is the only code that knows every slice.

A generated file starts with // @generated by Cratis. Source: <module>. Hash: <sha256>, and the output is deterministic, so committing it is safe. It lets a pull request show the contract change beside the Elixir change that caused it, and mix arc.gen.proxies --check in CI fails when a proxy is out of date. A file without the marker is never overwritten or deleted.

Every slice does one of four jobs, as in an event model:

Kind Does In Elixir
State change Changes state A command with handle/2
State view Serves state An Arc.ReadModel with queries, observable when the data changes
Automation Reacts and does side effects A Chronicle reactor or a Watch source
Translation Adapts one slice’s output into another’s command A reactor that executes a command

Co-location is about a behavior, not about copying. What several slices genuinely share, a concept such as an author id or a read model that more than one feature reads, belongs in a shared folder that the slices depend on. A slice reaches another slice only through a command, a query or a shared contract, never by importing its internals.