The UmimaClean Stack, Part 1: AdonisJS Structure, IoC, and the Request Lifecycle

How an AdonisJS 7 application is actually assembled - providers and preloads, the two middleware stacks and why the order matters, constructor dependency injection with no container registration, subpath imports, and validated environment variables.

A 13-part series on the UmimaClean stack. 1 — AdonisJS · 2 — VineJS · 3 — Lucid · 4 — Inertia · 5 — Transformers · 6 — Transmit · 7 — Leaflet · 8 — Midtrans · 9 — Fonnte · 10 — ExcelJS · 11 — Frontend · 12 — Security · 13 — Testing

I have written an overview of this stack already. This series is the long version — one part per technology, with the actual code and the actual problems.

Part 1 is the foundation everything else sits on: how an AdonisJS 7 application is wired together, and which of its conventions turned out to matter.

Why a Framework at All

The Node default is Express, or one of its faster descendants. You get a router and a middleware chain. Everything else is yours: validation, ORM, sessions, auth, CSRF, rate limiting, file storage, folder structure, how config is loaded, how the app boots in tests versus production.

That is a real advantage when you know exactly what you want and it is unusual. It is a liability when what you want is completely ordinary, because you spend the first two weeks making decisions that have well-known right answers, and then you make them slightly differently on the next project.

UmimaClean is completely ordinary in that sense. Sessions, roles, forms, a database, file uploads, a payment webhook. I wanted a framework that had already decided.

AdonisJS is the Node framework that behaves like Laravel or Rails: opinionated structure, batteries included, and — unlike most of that category — TypeScript-first rather than TypeScript-tolerating.

app/
  controllers/     grouped by role: admin/ auth/ customer/ staff/ webhooks/
  enums/
  exceptions/
  middleware/
  models/
  services/        every business rule lives here
  transformers/    model → plain object for the wire
  utils/
  validators/
config/            one file per package
database/
  migrations/
  factories/
  seeders/
start/             routes, kernel, env, validator, limiter
providers/
commands/
inertia/           the React frontend
tests/             unit/ functional/ browser/

The value is not that this layout is optimal. It is that it is the same layout as every other Adonis application, so “where does this go?” is never a discussion.

How the Application Boots

adonisrc.ts is the manifest. Three sections do the real work.

Providers are packages that register services into the IoC container at boot:

providers: [
	() => import('@adonisjs/core/providers/app_provider'),
	() => import('@adonisjs/core/providers/hash_provider'),
	{
		file: () => import('@adonisjs/core/providers/repl_provider'),
		environment: ['repl', 'test']
	},
	() => import('@adonisjs/core/providers/vinejs_provider'),
	() => import('@adonisjs/session/session_provider'),
	() => import('@adonisjs/shield/shield_provider'),
	() => import('@adonisjs/lucid/database_provider'),
	() => import('@adonisjs/inertia/inertia_provider'),
	() => import('@adonisjs/auth/auth_provider'),
	() => import('#providers/api_provider'),
	() => import('@adonisjs/drive/drive_provider'),
	() => import('@adonisjs/limiter/limiter_provider'),
	() => import('@adonisjs/transmit/transmit_provider')
]

Two details worth noticing.

They are dynamic imports, not static ones. Nothing is loaded until the framework decides to load it, which is what keeps boot time reasonable as the provider list grows.

And they can be environment-scoped. The REPL provider only loads in repl and test; production never imports it. That pattern generalises — a provider you only want in development is a two-line change rather than a conditional at the top of a file.

Preloads are your own files, imported before the app starts serving:

preloads: [
	() => import('#start/routes'),
	() => import('#start/kernel'),
	() => import('#start/validator')
]

start/validator.ts is the interesting one. It registers global VineJS behaviour — Indonesian error messages, and a transform that converts every validated date to a Luxon DateTime. Because it is a preload, that transform is in place before any request is handled, so no validator has to remember it. Part 2 goes into it properly.

Hooks run at init, and this is where the code generation lives:

hooks: {
  init: [
    indexEntities({
      transformers: { enabled: true, withSharedProps: true },
    }),
    indexPages({ framework: 'react' }),
    generateRegistry(),
  ],
  buildStarting: [() => import('@adonisjs/vite/build_hook')],
}

Three generators. indexEntities produces the types the frontend imports for transformed models. indexPages indexes the React pages so Inertia can resolve them by name. generateRegistry is Tuyau, building a typed route registry. All three run automatically in dev — the frontend’s types follow the backend without a build step anyone has to remember.

Two Middleware Stacks, and Why the Order Matters

This is the part of Adonis I would most want explained on day one, because the distinction is not obvious and it is load-bearing.

start/kernel.ts defines two stacks:

server.use([
	() => import('#middleware/container_bindings_middleware'),
	() => import('@adonisjs/static/static_middleware'),
	() => import('@adonisjs/cors/cors_middleware'),
	() => import('@adonisjs/vite/vite_middleware'),
	() => import('#middleware/inertia_middleware')
])

router.use([
	() => import('@adonisjs/core/bodyparser_middleware'),
	() => import('@adonisjs/session/session_middleware'),
	() => import('@adonisjs/shield/shield_middleware'),
	() => import('@adonisjs/auth/initialize_auth_middleware'),
	() => import('#middleware/silent_auth_middleware')
])

server.use runs on every HTTP request, including ones with no matching route. Static file serving belongs here — a request for /favicon.ico should never go near the router. So does CORS, which has to answer preflight requests for URLs that may not exist.

router.use runs only on requests that matched a route. Body parsing, sessions, CSRF, and auth all live here, because doing that work for a 404 is wasted.

The ordering inside router.use is a dependency chain, and every step needs the one before it:

bodyparser  →  parses the request body
session     →  needs cookies parsed, provides ctx.session
shield      →  needs the session to validate the CSRF token
auth init   →  needs the session to find the signed-in user
silent auth →  populates ctx.auth.user without requiring it

Move shield above session and CSRF silently stops working, because there is no session to compare the token against. This is the sort of thing that produces a confusing bug rather than an error message, so it is worth understanding once rather than discovering later.

Silent auth is the one people skip. It populates ctx.auth.user when a session exists and does nothing when it does not — no redirect, no exception. That is what lets the public home page know whether to render “Sign in” or “My orders” without being an authenticated route.

Named Middleware

The third export is the set of middleware you attach per route:

export const middleware = router.named({
	guest: () => import('#middleware/guest_middleware'),
	auth: () => import('#middleware/auth_middleware'),
	role: () => import('#middleware/role_middleware')
})

role takes a parameter, and it is the whole authorisation model:

export default class RoleMiddleware {
	async handle(ctx: HttpContext, next: NextFn, role: Role) {
		const { auth, response, session } = ctx
		const user = auth.getUserOrFail()

		if (user.role !== role) {
			session.flash('error', 'Anda tidak memiliki akses ke halaman ini')
			return response.redirect().toRoute(RoleRedirect[user.role as Role])
		}

		return await next()
	}
}

It never returns a 403 page. A customer who lands on an admin URL gets flashed an explanation and redirected to their own home. A 403 is the correct HTTP answer and the wrong product answer — the person is signed in, they are simply somewhere they do not belong, and a dead-end error page is a worse experience than being put back where they were.

Where “their own home” is lives in one place, next to the role enum:

export const RoleRedirect = {
	[Role.CUSTOMER]: 'customer.order.create',
	[Role.STAFF]: 'staff.trip.index',
	[Role.ADMIN]: 'admin.dashboard.index'
} as const

Three middleware use that map — role, guest, and the auth flow. Without it, “where does a staff member go after login?” would be answered separately in each, and they would drift.

Dependency Injection Without Registration

Adonis has an IoC container, and the thing that makes it pleasant is that you almost never touch it directly.

@inject()
export default class TaskService {
	constructor(
		private routeService: RouteService,
		private broadcastService: BroadcastService
	) {}
}

That is the entire wiring. No container registration, no factory, no module providers array. The @inject() decorator tells Adonis to read the constructor’s parameter types and resolve them — and because it resolves recursively, a controller asking for OrderService gets one with TaskService and TransactionService already inside it, each with their own dependencies filled.

The dependency graph in this app:

OrderService ──┬── TaskService ──┬── RouteService
               │                 └── BroadcastService
               └── TransactionService ── BroadcastService

AddressService ─── RouteService
AuthService ─────── FonnteService
OrderMessageService ─ FonnteService

Nobody writes a line of setup for any of it. Controllers get the same treatment:

@inject()
export default class OrderController {
	constructor(
		protected orderService: OrderService,
		protected addressService: AddressService
	) {}
}

The catch, which is worth knowing before it bites: this relies on TypeScript emitting design-time type metadata, so it only works for class types. You cannot inject an interface, and you cannot inject a primitive. In a codebase where services are concrete classes that is a non-issue; in one built around interface-first design it would be a real constraint.

Controllers Stay Thin

The layering rule the codebase actually holds to:

LayerMay doMay not do
ValidatorShape, type, format of a payloadKnow about status or roles
ControllerRead the request, call services, choose the pageContain a business rule
ServiceEnforce every business rule, own transactionsTouch request / response
TransformerTurn a model into the object a page receivesQuery, decide, or compute
ModelRelations and column mappingBusiness rules

A controller action in practice:

async store({ auth, request, response, session }: HttpContext) {
  const user = auth.getUserOrFail()
  const payload = await request.validateUsing(orderValidator)

  const order = await this.orderService.createOnlineOrder(user, payload)

  session.flash('success', 'Pesanan berhasil dibuat!')
  return response.redirect().toRoute('customer.order.show', { number: order.orderNumber })
}

Five lines: identify, validate, delegate, flash, redirect. Whether the pickup date is full, whether the address belongs to this customer, what the order number should be — none of that is here. It is all in createOnlineOrder, which is the only place it can be enforced and the only place anyone has to look.

request.validateUsing() is worth calling out: it validates and returns a fully typed payload. payload.pickupDate is a Luxon DateTime, payload.addressId is a number, and both are inferred from the validator rather than declared twice.

Subpath Imports

Every internal import in this codebase looks like this:

import Order from '#models/order'
import { OrderStatus } from '#enums/order_status_enum'
import TaskService from '#services/task_service'
import { orderValidator } from '#validators/order_validator'

Those are Node subpath imports, declared in package.json:

"imports": {
  "#controllers/*": "./app/controllers/*.js",
  "#models/*": "./app/models/*.js",
  "#services/*": "./app/services/*.js",
  "#validators/*": "./app/validators/*.js",
  "#enums/*": "./app/enums/*.js",
  "#utils/*": "./app/utils/*.js",
  "#database/*": "./database/*.js",
  "#start/*": "./start/*.js",
  "#config/*": "./config/*.js"
}

Not a bundler alias, not a tsconfig path mapping — a Node feature. Which means they resolve identically in dev, in the production build, in Ace commands, and in tests, with no per-tool configuration.

The practical win is that ../../../services/order_service never appears, so moving a file between directories does not rewrite the imports of everything around it.

Note the .js extensions on the right-hand side. This is an ESM project, TypeScript compiles .ts to .js, and Node resolves the emitted paths. It looks wrong the first time and it is correct.

Environment Variables That Fail at Boot

start/env.ts declares a schema, and the app refuses to start if reality does not match:

export default await Env.create(new URL('../', import.meta.url), {
	NODE_ENV: Env.schema.enum(['development', 'production', 'test'] as const),
	PORT: Env.schema.number(),
	HOST: Env.schema.string({ format: 'host' }),

	APP_KEY: Env.schema.secret(),
	APP_URL: Env.schema.string({ format: 'url', tld: false }),

	DB_HOST: Env.schema.string({ format: 'host' }),
	DB_PORT: Env.schema.number(),
	DB_PASSWORD: Env.schema.secret(),

	MIDTRANS_SERVER_KEY: Env.schema.secret(),
	FONNTE_API_KEY: Env.schema.secret()
})

Three things this buys.

Failure happens at boot, not at 3 AM. A missing MIDTRANS_SERVER_KEY stops the process on startup with a clear message, rather than surfacing as a confusing 500 the first time a customer tries to pay.

Types come out correct. env.get('PORT') is a number. No parseInt, no ! assertion.

Env.schema.secret() is not decoration. It wraps the value so it cannot be logged or serialised by accident — printing it gives you a redacted placeholder. Reading it takes an explicit unwrap:

serverKey: env.get('MIDTRANS_SERVER_KEY').release()

That .release() call is a small piece of friction in exactly the right place. Accidentally logging a config object containing a server key is a genuinely common way to leak one, and this makes it hard to do without meaning to.

Ace Commands

Ace is the CLI. The generators are ordinary (node ace make:controller, make:migration), but the part that mattered was writing my own:

export default class PruneRecords extends BaseCommand {
	static commandName = 'prune:records'
	static description = 'Delete expired proof photos and addresses nothing points at any more'

	static options: CommandOptions = {
		startApp: true
	}

	async run() {
		const photos = await this.prunePhotos()
		const addresses = await this.pruneAddresses()

		this.logger.info(`Removed ${photos} proof photo(s) past ${PHOTO_RETENTION_DAYS} days.`)
		this.logger.info(`Removed ${addresses} orphaned address(es).`)
	}
}

startApp: true boots the full application, so the command has the container, the database, and every service available exactly as an HTTP request would. Housekeeping is written against the same models as everything else rather than against raw SQL in a script.

There is no queue in this system and no scheduler package. The command is pointed at by cron or a systemd timer, and both of its jobs are safe to run twice and safe to miss. That is a deliberately low-tech answer, and for one shop it is the correct amount of machinery.

The Cost

Being fair about the trade.

The ecosystem is small. When something breaks, the answer is in the Adonis docs, the Discord, or the framework source. Stack Overflow will not have it. An LLM trained mostly on Express will confidently invent an answer that does not exist.

Version drift in the search results. Adonis 7 is recent, and a lot of what is findable online is written for v5 or v6. The APIs moved. You learn to check the version on anything you find.

The learning curve is front-loaded. IoC, providers, two middleware stacks, subpath imports — none of it is difficult, but there is a week of it before you are productive. Express has no such week, and it also never stops charging you for the decisions it declined to make.

What made the trade acceptable is that the source is readable. Twice I ended up inside node_modules/@adonisjs to answer a question, and both times the answer was obvious once I was there. A small ecosystem with a comprehensible codebase is a very different proposition from a small ecosystem you cannot see into.


Next: Part 2 — VineJS: Validation as a Layer, where request validation stops being a per-form chore and error messages get written once.

© 2026 r3p.dev. All rights reserved.