Modulith Architecture & Concurrency Guide

Applies to: zmodu v0.35+ · Zig 0.17

This guide explains how to structure a zmodu application as a modulith (a single deployable binary with strict internal module boundaries) and scale it for high-concurrency workloads.

This document covers the application plane (modules, fibers, connection pools, caching, and messaging). For stateful workers, bounded mailboxes, lock-free queues, and deterministic replay, see the Runtime Reference.

1. Core Principles

  1. Define module boundaries on day one: Declare explicit dependencies in Module.info.dependencies. The compiler rejects circular or missing dependencies during ApplicationBuilder.build.
  2. Call services in-process for strong consistency: Use persistence.Tx helpers across modules when operations must share a single database transaction.
  3. Publish events for cross-domain side effects: Use app.eventBus(T) for in-process notifications and the transactional outbox (zmodu.outbox.*) for reliable asynchronous delivery.
  4. Scale horizontally before splitting services: Run stateless application instances behind a load balancer with shared database pools, Redis, and Kafka/RobustMQ. Extract a module into a separate process only when independent scaling or deployment is required.
+---------------------------------------------------------+
|                 Application (Single Process)            |
|  +--------+   +---------+   +--------+   +---------+    |
|  |  user  |   | product |   | order  |   | payment |    |
|  +---+----+   +----+----+   +---+----+   +----+----+    |
|      +-------------+--- EventBus -------------+         |
|  DB Pool · Redis · RobustMQ · JWT / RateLimit / Breaker |
+---------------------------------------------------------+

2. Module Boundaries and Layout

2.1 Standard Five-File Module Structure

Each domain module under src/modules/<domain>/ uses a flat five-file layout (with an optional root.zig barrel):

src/modules/<domain>/
├── model.zig         # Data structs, table names, and pure domain helpers
├── persistence.zig   # Parameterized SQL queries (`?` placeholders) and `pub const Tx`
├── service.zig       # Business validation, transactions, and event publishing
├── api.zig           # Declarative `pub const routes` and HTTP handlers
├── module.zig        # `pub const info`, `init` / `initWith`, and `deinit`
└── root.zig          # Barrel exports
LayerResponsibilityDisallowed
apiParse inputs, read auth attributes, serialize JSON responsesBusiness branching, transaction orchestration, raw SQL
serviceValidate inputs, open transactions, call Tx helpers, publish eventsDirect access to http.Context
persistenceParameterized SQL queries, pub const Tx unit-of-work helpersBusiness branching, reading wall/monotonic clocks
moduleModule metadata, dependency list, lifecycle hooksBusiness logic
modelRow structs, sql_table_name, enum conversionsSQL queries, I/O

2.2 Module Contract

pub const info = zmodu.api.Module{
    .name = "order",
    .description = "Order management",
    .dependencies = &.{ "user", "product" }, // Module names, not file paths
};

pub fn init() !void {}
pub fn deinit() void {}

2.3 Cross-Module Communication Rules

ScenarioMechanismReference
Atomic multi-table writeOrchestrating service + sibling persistence.TxMODULE_LAYERS.md
Cross-domain side effectapp.eventBus(T) or transactional outboxEVENTS_DI.md
Cross-instance messagingKafka / RobustMQ (zmodu.KafkaProducer)DISTRIBUTED.md

Do not import another module’s internal files directly; zmodu doctor flags cross-module file imports.

3. High-Concurrency Practices

The HTTP server uses an asynchronous M:N fiber model (kqueue on macOS/BSD, epoll/io_uring on Linux).

3.1 Day-One Configuration

  1. Database connection pooling: Configure max_open and max_idle on data.Client / ConnPool. Never open a new database connection per request.
  2. Per-request arena allocation: Allocate request-scoped data from ctx.allocator. Do not store request pointers in long-lived globals.
  3. Non-blocking handlers: Avoid synchronous blocking calls (posix.poll, posix.read, or long sleeps) inside HTTP handlers; offload heavy work to the outbox or a .blocking runtime worker.
  4. Idempotent writes: Protect payment, order creation, and inventory mutations with business idempotency keys or idempotencyMiddleware.
  5. Connection backpressure: Configure http.productionProfile with max_connections, header_timeout_ms, and body_timeout_ms.

3.2 Scaling Stages

StageArchitectureKey Additions
0–1k DAUSingle-node modulith + SQLite/PostgreSQLStrict module boundaries, EventBus
1k–10k DAUVertical scalingConnPool, Redis cache, rate limiting, circuit breakers
10k–100k+ DAUStateless horizontal replicas + LBShared Redis rate limiter, transactional outbox + Kafka/RobustMQ, read replicas
Service extractionSplit specific module into standalone binaryOnly when resource profile or release cadence diverges

4. Application Bootstrap Pattern

Keep src/main.zig focused on wiring infrastructure, registering services, and mounting routes:

const std = @import("std");
const zmodu = @import("zmodu");

pub fn main(init: std.process.Init) !void {
    const allocator = init.gpa;
    const io = init.io;

    var b = zmodu.builder(allocator, io);
    defer b.deinit();
    var app = try b.withName("shop")
        .build(.{ UserModule, ProductModule, OrderModule, PaymentModule });
    defer app.deinit();

    try app.start();
    defer app.stop();
}

5. Per-Application Module State

Do not store mutable module state in file-scope pub var variables, as they persist across multiple Application instances in tests or multi-phase runs. Use initWith and ctx.state(T) to allocate state scoped to the Application instance:

const BookState = struct {
    open_orders: usize = 0,
    pub fn deinit(self: *@This()) void {
        _ = self;
    }
};

pub fn initWith(ctx: *zmodu.core.ModuleContext) !void {
    const st = try ctx.state(BookState);
    st.open_orders = 0;
}
  • State is keyed by (module_name, T).
  • app.stop() leaves the state readable for module deinit hooks; app.deinit() calls T.deinit (if declared) in reverse creation order and frees the memory.

6. Zig 0.17 Concurrency & Safety Rules

AreaRule
MutexesUse std.Io.Mutex with .lock(io) / .unlock(io)
Dynamic arraysUse std.ArrayList(T).empty and pass allocator to each method
TimeUse Time.monotonicNowMilliseconds() for durations/deadlines and Time.wallClockSeconds(io) for stored timestamps
HTTP headersHeader keys in Context.headers are lowercase (e.g., "authorization")
SQLAlways use ? parameter placeholders; never interpolate user input
HTTP responsesUse ctx.json / ctx.jsonStruct / http.respondErr; do not use deprecated sendSuccess / sendFail