03. Architecture
Packages
@avelon/core contracts, config resolution, service registry, events, errands,
validation, console, logging, error taxonomy, testing primitives.
Zero imports from next/*. Zero Bun-only APIs.
@avelon/orm Scrivener. Models, relations, scopes, casts, lifecycle hooks, and the
query IR builder. Depends on the database contract, never on a driver.
@avelon/supabase Driver package. Implements database, identity, social, tokens, storage,
queue. Compiles wards to RLS.
@avelon/postgres Driver package. Implements database with transactions and deep relations.
@avelon/resend Driver package. Implements mail.
@avelon/next Adapter. Route codegen, server action dispatch, page wrapper, middleware
bridge, view() to RSC.
@avelon/bailiff ESLint plugin plus CLI wrapper. Architecture rules.
@avelon/conformance Shared test suites, one per contract. Drivers run these to prove themselves.
@avelon/cli reeve. TUI, generators, stubs. Discovers adapter and driver stubs at runtime.
create-avelon Scaffolder.
The dependency rule is one-directional and enforced by Bailiff: core knows nothing about orm, orm knows nothing about drivers, drivers know nothing about the adapter, and the adapter knows nothing about a specific driver.
The adapter contract
Deliberately small. The larger this gets, the less true "framework agnostic" becomes.
export interface Adapter {
readonly name: string
readonly capabilities: {
fileSystemRouting: boolean
serverActions: boolean
streaming: boolean
edgeMiddleware: boolean
}
/** Codegen for file-routing adapters, runtime registration for others. */
mount(manifest: RouteManifest, options: MountOptions): Promise<MountResult>
toRequest(native: unknown): Promise<HttpRequest>
toResponse(result: ActionResult | ViewResult): Promise<unknown>
}
capabilities exists so the CLI and codegen can choose the right strategy for the adapter, rather than emitting code that fails at runtime. Revised after M0 (D26): serverActions selects the write transport, it does not gate whether a write route exists. An adapter reporting serverActions: true serves Route.post() as a generated server action; one reporting false mounts an ordinary POST route for the same controller. The M0 Hono spike served both patterns from one unchanged controller.
Two more rules keep the contract portable:
- Views are opaque.
ViewResult.viewis a reference core never inspects. The configured adapter
both accepts and renders it: under Next it is an RSC component import, under another adapter it is whatever that adapter's renderer resolves. A controller returning view(Show, props) is portable because nothing outside the adapter ever opens Show.
- Adapters delegate to the kernel. Middleware, route-model binding, validation error shaping,
and exception mapping live in core's dispatch pipeline. mount() wires routes to that pipeline; it does not reimplement it. An adapter is a translation layer at exactly three seams: mount, toRequest, toResponse.
Directory structure
avelon-app/
├── reeve # CLI entry, bun shebang
├── app/
│ ├── (web)/ # GENERATED router. Never hand-edited.
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ └── posts/
│ │ ├── page.tsx
│ │ ├── create/page.tsx
│ │ └── [post]/
│ │ ├── page.tsx
│ │ └── edit/page.tsx
│ ├── (api)/ # GENERATED, only for .api() routes
│ ├── Actions/ # single-purpose use cases
│ ├── Commands/ # console commands
│ ├── Drivers/ # your own thin vendor adapters, the only place raw() lives
│ ├── Errands/ # background work
│ ├── Events/
│ ├── Http/
│ │ ├── Controllers/
│ │ ├── Middleware/
│ │ ├── Requests/ # Zod input contracts
│ │ └── Views/ # serializers for the RSC boundary
│ ├── Listeners/
│ ├── Models/
│ ├── Notices/ # mail and notifications
│ ├── Observers/
│ ├── Policies/ # may this actor perform this action
│ ├── Providers/
│ └── Wards/ # which rows may this actor reach
├── config/ # typed, one file per concern
├── database/
│ ├── migrations/ # driver-owned format, one history
│ ├── seeds/
│ ├── factories/
│ └── types.ts # GENERATED from the live schema
├── resources/
│ ├── css/app.css
│ └── views/
│ ├── components/
│ ├── layouts/
│ └── posts/
├── routes/
│ ├── web.ts
│ ├── api.ts
│ ├── console.ts
│ └── channels.ts
├── storage/logs/
├── tests/{Feature,Unit}/
├── public/
├── middleware.ts # adapter entry, delegates to the kernel
├── avelon.config.ts # adapter and driver wiring
├── vercel.json
└── package.json
Why colocation inside app/ works
Next only treats page, layout, route, loading, error, template, and default files as routable. Everything else inside app/ is documented colocation. app/Models/Post.ts is not a route, and app/Models/ never becomes a URL segment because it contains no page.tsx.
Route groups keep all generated files in two entries, so the directory reads as the application tree with two extra folders for the generated router.
This is assumption 1 of the M0 spike. Everything structural depends on it.
Routing
routes/web.ts is the source of truth. reeve route:sync generates the router from it. Generated files are four lines and contain no logic, which is what keeps adapter upgrades cheap.
import { Route } from '@avelon/core'
import { PostController } from '@/app/Http/Controllers/PostController'
import { Post } from '@/app/Models/Post'
Route.get('/', HomeController, 'index').name('home')
Route.middleware('auth').group(() => {
Route.resource('posts', PostController).bind('post', Post)
Route.get('/dashboard', DashboardController).name('dashboard')
})
Route.prefix('admin').middleware('auth', 'can:admin').group(() => {
Route.resource('users', UserController).only('index', 'show')
})
Route.resource()expands to seven routes: index, create, store, show, edit, update, destroy..bind('post', Post)is route-model binding. The controller receives a hydrated model and a miss
is a 404 before your code runs.
.api()additionally generates a JSON route handler.route('posts.show', id)resolves from a generated manifest, so it works in server and client
components without importing controllers.
Codegen is one-way and destructive. The router directory is wiped and rewritten on every sync. Two GET routes resolving to the same file is a hard error at sync time. Sync runs in build, in dev via a watcher on routes/, and in a pre-commit hook.
Request lifecycle
Read:
browser
-> middleware.ts
-> kernel: global middleware, then route middleware from the manifest
-> app/(web)/posts/[post]/page.tsx (generated, 4 lines)
-> page(PostController, 'show', '/posts/{post}')
-> HttpRequest.fromPage()
-> route-model binding: Post.findOrFail(params.post)
-> PostController.show(request, post)
-> Gate.authorize('view', post)
-> view(Show, { post: PostView.make(post) })
-> RSC render
Write:
<Form action="posts.update" params={{ post: id }}>
-> generated server action posts_update(formData)
-> dispatch(PostController, 'update', '/posts/{post}', formData)
-> HttpRequest.fromFormData() (_method spoofing, param extraction)
-> middleware chain
-> route-model binding
-> PostController.update(request, post)
-> Gate.authorize('update', post)
-> UpdatePostRequest.validate(request) throws ValidationError
-> post.update(data) fires model events
-> Event.dispatch(new PostUpdated(post))
-> redirect(route('posts.show', post.id))
-> ValidationError caught -> { ok: false, errors } -> useActionState -> field errors
-> success -> redirect propagates -> navigation
Exceptions map in one place: ModelNotFound to 404, Unauthenticated to the login page with an intended URL, Forbidden to 403, ValidationError back to the form with per-field errors.
Controllers
export class PostController extends Controller {
async index() {
const posts = await Post.published().with('author').latest().paginate(15)
return view(Index, { posts: PostView.collection(posts) })
}
async show(request: HttpRequest, post: Post) {
await this.authorize('view', post)
return view(Show, { post: PostView.make(post) })
}
async store(request: HttpRequest) {
await this.authorize('create', Post)
const data = await StorePostRequest.validate(request)
const post = await Post.create({ ...data, user_id: (await this.userOrFail()).id })
await Event.dispatch(new PostPublished(post))
return redirect(route('posts.show', post.id))
}
}
Controllers stay thin. Anything past a handful of statements moves to app/Actions/, and Bailiff warns when it does not.
Model instances are class instances and cannot cross into client components. app/Http/Views/ holds the serializers, and passing a raw model to view() is a Bailiff error rather than a runtime surprise.