How Dox works
This page explains what happens inside Dox when a request comes in. It also explains how Dox uses isolates, where the database pools live, and what each folder of an app is for.
The life of a request
Dox has two parts:
- A native server, written in Rust (
dox_http_native). It owns the sockets and parses HTTP. - Your app, written in Dart. Routes, middleware, DTOs, controllers and the ORM all run here.
A request goes through these steps:
- Rust accepts the connection and reads the request. It parses the HTTP request line, the headers and the body.
A bad request gets
400. Headers over 16 KB get431. A body over the size limit gets413. Dart never sees these requests. - Rust hands the requests to Dart in a batch. One message holds many requests.
- Dart finds the route. It matches the method, the
Host(for domain routes) and the path. A plain path is a map lookup. A path with{params}is a pattern match. No match gives404. A CORS preflight (OPTIONS) gets204. - Dart adds the CORS headers of the route.
- Dart reads the body, only when the request has a
Content-Type. A request without a body skips this step. - Dart runs the middleware list of the route, in this order: global middleware, router middleware, group middleware, route middleware. A middleware returns the request to go on. Anything else is sent as the response, and the controller does not run.
- Dart fills the controller parameters. Path params are converted first. A value that does not fit its type (
abcforint id) gets404. Then the DTO is read and checked. Bad input gets422. A body that is not JSON gets400. A content type Dox can not read gets415. - The controller runs and returns a value.
- The
responseHandlerfromAppConfigruns, when you set one. It can change every response. - Dox turns the value into the response: a
Stringis text, aMapor model is JSON, aFileis streamed. - Dart sends the answers of the batch back to Rust in one call. Rust writes the bytes to the socket.
When a step throws, Dox catches the error. An IHttpException (like NotFoundHttpException) gives its own status.
Any other error gives 500 and goes to the errorHandler of AppConfig.
sequenceDiagram
participant C as Client
participant R as Rust (dox_http_native)
participant D as Dart isolate
participant M as Middleware
participant K as Controller
C->>R: TCP connection, HTTP request
R->>R: parse request, check size limits
R->>D: batch of requests (one message)
D->>D: router: method, host, path
D->>D: CORS headers, read body
D->>M: global, router, group, route middleware
M-->>D: request (go on) or a response
D->>D: path params, DTO check
D->>K: call with typed parameters
K-->>D: return value
D->>D: responseHandler, build the response
D->>R: batch of answers (one call)
R->>C: bytes
No reflection
Dox reads the parameter types of every controller once, at start.
It does not use dart:mirrors. DTOs come from a generated list (lib/dtos.g.dart).
This is why dox build can make a native (AOT) binary.
Where things are decided
| Step | Code | What it decides |
|---|---|---|
| Route match | lib/routes/*.dart, Route.get(...) |
which controller runs |
| Middleware | AppConfig(globalMiddleware:), Router.middleware, Route.middleware(...), the route list |
go on, or answer early |
| Input | a @Dto class in lib/app/http/dtos/ |
what the controller gets, or 422 |
| Output | the return value, response(...), responseHandler |
status, headers, body |
| Errors | IHttpException, AppConfig(errorHandler:) |
the error response, logging |
See Routing, Middleware, Validation and Response.
Isolates
Dart runs code on isolates. An isolate is like a thread, but it has its own memory.
Dox starts one isolate per CPU core (Platform.numberOfProcessors). Every isolate runs the full app.
One port, many isolates
Every isolate runs its own HTTP server on the same port. The operating system spreads the connections.
| System | Native server | dart:io server |
|---|---|---|
| Linux | one socket per isolate (SO_REUSEPORT) |
HttpServer.bind(..., shared: true) |
| macOS | the isolates share one socket | HttpServer.bind(..., shared: true) |
| Windows | only one isolate serves HTTP | HttpServer.bind(..., shared: true) |
Each isolate also has its own Rust network thread. DOX_HTTP_THREAD=shared puts all isolates on one thread. Leave it off unless you measure a gain.
What each isolate gets
Before it starts an isolate, Dox copies three things to it:
- the
AppConfig, - the list of services,
- the routes you added before
startServer().
Then each isolate does this, once:
- It runs
setup()of every service, in list order. This is where a database pool is opened. - It registers the routes of your routers.
- It reads the parameter types of every controller.
- It starts its HTTP server.
Nothing in this list runs per request.
Because the config and the services are copied, they must not hold open sockets or pools.
Open these in setup(). See Services.
Each isolate has its own memory
- A static value or a singleton exists once per isolate. A change in one isolate is not seen in the others.
- The
memorycache store is per isolate. Use theredisstore to share cache data. - To share data between isolates, use the database, Redis or files.
Dox().isolateIdtells you which isolate runs the code. The first one is1.
When an isolate crashes
- An error in a request is caught. Dox answers
500and keeps going. - An async error outside a request is caught and passed to
errorHandler. The isolate keeps running. - When a worker isolate still dies, Dox prints
[dox] isolate N exited, restartingand starts a new one after 1 second. - When the first (main) isolate dies, the process exits with a non-zero code. A supervisor (Docker
restart:, systemdRestart=always) must start it again.
How many isolates
Set totalIsolate in AppConfig, or APP_TOTAL_ISOLATE in .env. In code, Dox().totalIsolate(2) before startServer() wins over the config.
Use 1 in tests. See Isolate.
Where the database pools live
The ORM is started in a service (ORMService). Every isolate runs setup(), so every isolate opens its own pool.
| Driver | Per isolate | Per process |
|---|---|---|
Postgres native (dox_postgres_native) |
one pool of maxConnectionCount connections |
one Rust runtime, shared by all pools (extra['threads'], default 1) |
MySQL native (dox_mysql_native) |
one pool | one Rust runtime, shared by all pools |
Dart drivers (postgres, mysql_dart) |
one pool | nothing shared |
| SQLite | one connection | nothing shared |
So the total number of connections is isolates × maxConnectionCount.
With 8 cores and the default of 10, the app can open 80 connections. Set maxConnectionCount with this in mind.
Share one native pool between isolates
A native pool has a handle (an int). Give it to another isolate and use
PostgresNativeDriver(PgNativePool.fromHandle(handle)) there. Then both isolates use one pool.
See Native Postgres driver.
The native drivers are the default. When the native library does not load, the ORM uses the Dart driver and prints one warning line.
Set DOX_DB_DRIVER=dart to use the Dart drivers on purpose. See Database.
The layout of a Dox app
dox create my_app copies the dox-sample template. It looks like this:
my_app/
├── bin/
│ └── server.dart # starts the app
├── db/
│ └── migration/ # migrations (.dart with a registry, or .sql)
├── lib/
│ ├── app/
│ │ ├── http/
│ │ │ ├── controllers/ # *.controller.dart
│ │ │ ├── dtos/ # *.dto.dart, typed and checked input
│ │ │ ├── middleware/ # *.middleware.dart
│ │ │ ├── serializers/ # *.serializer.dart, custom JSON shapes
│ │ │ └── response_handler.dart
│ │ ├── models/ # one folder per model, *.model.dart
│ │ └── ws/controllers/ # WebSocket controllers (dox_websocket)
│ ├── config/
│ │ ├── app.dart # AppConfig: port, isolates, routers, middleware
│ │ ├── cache.dart # cache stores
│ │ ├── cors.dart
│ │ ├── database.dart # DatabaseConfig: connections
│ │ ├── env.dart # typed env class (@DoxEnv), optional
│ │ ├── logger.dart
│ │ ├── services.dart # the list of DoxService
│ │ └── storage.dart # storage disks
│ ├── routes/
│ │ ├── api.dart # ApiRouter, prefix `api`
│ │ └── web.dart # WebRouter
│ ├── services/
│ │ └── orm_service.dart # opens the database in every isolate
│ ├── dtos.g.dart # generated: the list of DTOs
│ └── openapi.g.dart # generated: the OpenAPI docs
├── storage/ # local disk, file cache, uploads
├── test/
├── .env # local settings, not in git
├── .env.example
├── Dockerfile
├── docker-compose.yml
└── pubspec.yaml
| Folder | What goes there |
|---|---|
bin/ |
server.dart calls Dox().initialize(appConfig), runs the migrations, then Dox().startServer(). dox make:migration adds migrate.dart. |
db/migration/ |
Your migrations. Dart migrations are listed in migrations.dart, which the CLI writes. |
lib/app/http/ |
Everything about HTTP: controllers, DTOs, middleware, serializers. |
lib/app/models/ |
One folder per model. build_runner writes the .g.dart file next to it. |
lib/config/ |
One file per config object. app.dart puts them together in AppConfig. |
lib/routes/ |
One Router class per file. Add each router to routers in app.dart. |
lib/services/ |
Classes that implement DoxService. Their setup() runs in every isolate. |
lib/*.g.dart |
Generated files. Do not edit them. Run dox build_runner:build to make them again. |
storage/ |
Files the app writes: uploads, the file cache, SQLite files. |
Generated code comes from build_runner with the dox_builder package. It makes the model classes, the DTO checks, the typed env class and the OpenAPI docs.
Run dox build_runner:build after you change a model, a DTO or the env class. dox serve runs it in watch mode for you.