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

Your first module

A fresh application is laid out by kind: every controller in app/controllers/, every service in app/services/, every page in app/pages/. That is the right shape while there is one feature. It stops being the right shape at ten, when “show me everything billing does” means opening seven directories and knowing which files in each are billing’s.

A module is the other shape. One directory owns one feature’s controller, service and routes, and a module! block at its root is the index of what is inside. The two layouts coexist deliberately — the scaffold’s own Web module keeps the by-kind directories, and an application is free to use one, the other, or both.

This page goes from arc new to a route that answers, without editing a file by hand.

Generate it

$ arc new acme --stack react --db sqlite
$ cd acme
$ arc make:module billing
created app/modules/billing/mod.rs
updated app/modules/mod.rs
created app/modules/billing/controller.rs
created app/modules/billing/service.rs
created app/modules/billing/routes.rs

Four files written, one updated. The module is registered: nothing else has to be touched before it serves.

$ arc serve --port 3000
$ curl localhost:3000/billing
BillingController::index

What landed

app/modules/billing/mod.rs

The index. module! records what this feature contains, so the application graph — and through it arc routes, arc typegen and arc build — can see it.

pub mod controller;
pub mod routes;
pub mod service;

use arcature::prelude::*;

use controller::BillingController;

module! {
    pub Billing {
        controllers: [BillingController],
        services: [BillingService],
        routes: routes::BILLING_ROUTES,
    }
}

BillingService is named but not imported, and that is not an oversight. controllers: and routes: are resolved at this site — the first reads the controller’s ControllerMetadata::METHODS, the second is a path to a const — while services: and policies: are recorded as names only. Importing a type the macro never resolves would be an unused import.

app/modules/billing/controller.rs

use arcature::prelude::*;

pub struct BillingController;

#[controller]
impl BillingController {
    pub async fn index() -> Result<Response> {
        Ok(text(StatusCode::OK, "BillingController::index"))
    }
}

Result, Response, StatusCode and text all come from the prelude, which is the point: a controller should not need a use list before it does any work.

app/modules/billing/service.rs

#[service]
pub struct BillingService {
    db: Db,
}

#[service] generates Resolve<S>, which composes the struct from the application’s resources per request: each field type is resolved in turn, so a service may hold another service as a field. Keep the methods framework-agnostic — take domain values, return domain values, and let the controller map the result to HTTP.

app/modules/billing/routes.rs

use super::controller::BillingController;
use crate::bootstrap::AppState;

routes! {
    pub billing {
        state: AppState;

        get "/billing" => BillingController::index { name: billing.index }
    }
}

The path is absolute. Living in a module adds no prefix — a module is a unit of source organisation here, not a mount point. Reach for a route group when you want a shared prefix, inside the module or outside it.

The route name carries the module’s own, and that matters more than it looks. app/modules/mod.rs merges every module’s routes into one table. Two modules claiming the same path is a panic at boot, from Axum, so it cannot reach production unnoticed. Two modules claiming the same name is not: the later one silently wins, and url_for("index", ..) starts resolving somewhere else. Namespacing the name is what makes ten modules safe to merge.

How it is wired

arc make:module adds three lines to app/modules/mod.rs: the pub mod declaration, one entry in the descriptor list, and one in the route list.

pub fn modules() -> Vec<ModuleDescriptor> {
    vec![
        // arc:modules descriptors
        billing::billing_module().clone(),
        // arc:end
    ]
}

pub fn routes() -> Routes<AppState> {
    let collections: Vec<Routes<AppState>> = vec![
        // arc:modules routes
        billing::routes::billing_routes(),
        // arc:end
    ];
    collections.into_iter().fold(Routes::empty(), Routes::merge)
}

app/mod.rs appends modules() to the scaffold’s Web module before handing the list to ApplicationGraph::new; bootstrap/app.rs merges routes() into the application’s table. Both were already written by arc new — the generator only ever inserts into the two marked regions.

Editing the file by hand is fine. Reorder the entries, reformat them, add one yourself. The generator matches the arc:modules / arc:end markers and nothing else about the surrounding text, and it skips an entry that is already there, so re-running it after deleting a directory does not leave a duplicate behind.

Deleting a marker is the one thing that breaks it. When that happens it says so and writes the module’s files anyway, leaving one line for you to paste:

$ arc make:module billing
created app/modules/billing/mod.rs
updated app/modules/mod.rs
note: app/modules/mod.rs has no `// arc:modules routes` marker -- add `billing::routes::billing_routes(),` to its routes list by hand
created app/modules/billing/controller.rs
...

Nested names

A name with slashes nests, and the intermediate mod.rs files are created as needed:

$ arc make:module admin/reports
created app/modules/admin/reports/mod.rs
updated app/modules/admin/mod.rs
updated app/modules/mod.rs

The registration follows the nesting — admin::reports::reports_module() — and so does the route name, which becomes admin.reports.index. The URL does not: routes.rs still declares an absolute path, so the module serves /admin/reports because that is what is written there, not because of where it sits on disk.

What the graph checks

ApplicationGraph::new runs at boot and rejects three wiring mistakes:

GraphErrorMeans
DuplicateModuletwo modules declared the same name
UnknownImportan imports: entry names a module the graph does not hold
CircularDependencymodules import each other in a loop; the error lists the cycle in order

app/mod.rs calls .expect(..) on the result, so all three are a panic at boot in the scaffold. That is the honest answer: each one is the same on every run and has nothing to do with the request, so surfacing it on one unlucky request later would only make it harder to place.

A type missing from a module! block still compiles and still serves. It is simply invisible to the graph, and therefore to arc routes, arc typegen and arc build. That is the trap module! exists to close, one level up.

What is deliberately not scaffolded

A module gets four files, not five. arc make:policy is one command away, but a generated policy cannot compile until it is pointed at a model and a user type — Policy<M> bounds its associated User by AuthUser, so there is no placeholder that type-checks. Shipping one inside a module would mean arc make:module billing produces a project that does not build.

Add one once the feature has a model and a user type to point it at:

$ arc make:policy invoice
created app/policies/invoice.rs
updated app/policies/mod.rs
note: invoice names `Invoice` and `User`; point them at the model this policy guards and the application's user type

That lands in app/policies/, not in the module — nearly every make kind writes to the by-kind directory it belongs to. The two exceptions are make:module, because a module is a directory, and make:auth, which writes a feature’s worth of files into app/auth/. Move the file under app/modules/billing/policy.rs if you want the feature to own it, add the pub mod policy; line, and name the type in the module’s policies: list. Nothing in the generator or the graph depends on where the file sits; the list is what the graph reads.

The module! sections

SectionHoldsResolved at the call site?
importsmodule names this one depends onno — names
exportsmodule names this one providesno — names
controllerscontroller typesyes — reads ControllerMetadata::METHODS
servicesservice type namesno — names
policiespolicy type namesno — names
routesa path to a &'static [RouteDescriptor] constyes
listenersevent → listener name pairsno — names
jobsjob kind, version and handler nameno — names
commandscommand name → function name pairsno — names
schedulesjob kind, version and cadenceno — names
pagespaths to #[page] typesyes — reads PAGE_CONTRACT_ENTRY

Order between sections is free, every section is optional, and the trailing comma is optional. The right-hand column is the whole rule for what has to be in scope: a name is a string the graph compares, a resolved entry is a type or const the macro reads through. Which way a section falls decides what a typo costs. A misspelled entry in controllers: is an ordinary “cannot find type” at the module! line. A misspelled entry in services: compiles, because the macro only ever records the string — the graph reports the name you wrote, and nothing anywhere disagrees with it.

Next

Routing for groups, middleware and the route table; Controllers for what #[controller] emits; Testing for driving a module’s routes without a server.