Filling the Unit System: IntegrationUnit, AdapterUnit, and the Full Boot Sequence
Source: Dev.to
Filling the Unit System – IntegrationUnit, AdapterUnit and the Full Boot Sequence
Source: Dev.to – Filling the Unit System
Table of Contents
Overview
The Unit System in a modular application provides a clean way to separate concerns, manage dependencies, and orchestrate the start‑up of each component.
Two core building blocks are:
| Block | Responsibility |
|---|---|
| IntegrationUnit | Registers services, repositories, and any other internal dependencies. |
| AdapterUnit | Exposes the integration layer to the outside world (e.g., HTTP, gRPC, message‑bus). |
Understanding how these units are wired together and how the full boot sequence works is essential for building maintainable, testable applications.
IntegrationUnit
What it does
- Registers domain services, repositories, use‑cases, and infrastructure components.
- Keeps the internal graph of dependencies isolated from external adapters.
Typical structure
// Kotlin example – IntegrationUnit.kt
class IntegrationUnit(
private val config: AppConfig,
private val db: Database
) {
// 1️⃣ Repositories
fun userRepository(): UserRepository = UserRepositoryImpl(db)
// 2️⃣ Services / Use‑cases
fun userService(): UserService = UserServiceImpl(userRepository())
// 3️⃣ Additional infrastructure (e.g., event bus)
fun eventBus(): EventBus = EventBusImpl()
}Key points
- Pure Kotlin/Java – no framework‑specific annotations.
- All objects are created lazily (or eagerly, depending on the design).
- The unit can be instantiated in tests with mock dependencies, enabling fast unit testing.
AdapterUnit
What it does
- Takes the public contract (HTTP routes, gRPC services, CLI commands, etc.) and delegates to the
IntegrationUnit. - Handles serialization, validation, authentication, and error mapping.
Typical structure
// Kotlin example – HttpAdapterUnit.kt
class HttpAdapterUnit(
private val integration: IntegrationUnit,
private val router: Router = Router()
) {
init {
// Register routes
router.get("/users/{id}") { ctx ->
val id = ctx.pathParam("id")
val result = integration.userService().getUser(id)
ctx.json(result)
}
router.post("/users") { ctx ->
val payload = ctx.bodyAsClass(CreateUserRequest::class.java)
val created = integration.userService().createUser(payload)
ctx.status(201).json(created)
}
}
fun start(port: Int = 8080) = router.start(port)
}Key points
- Thin layer – it should contain no business logic.
- Keeps the adapter code (e.g., Ktor, Spring MVC, Express) separate from the core logic.
- Makes it trivial to swap adapters (e.g., replace HTTP with gRPC) without touching the integration layer.
Full Boot Sequence
The boot process stitches the two units together and starts the application.
A typical sequence looks like this:
- Load configuration (environment variables, config files, etc.).
- Create infrastructure resources (database pool, message‑bus client, etc.).
- Instantiate
IntegrationUnitwith the resources from step 2. - Instantiate
AdapterUnitpassing theIntegrationUnit. - Start the adapter (listen on a port, subscribe to a queue, etc.).
Example boot file
// Kotlin – Main.kt
fun main() {
// 1️⃣ Load config
val config = AppConfig.load()
// 2️⃣ Initialise infrastructure
val db = Database.connect(config.dbUrl, config.dbUser, config.dbPassword)
// 3️⃣ Build IntegrationUnit
val integration = IntegrationUnit(config, db)
// 4️⃣ Build AdapterUnit
val httpAdapter = HttpAdapterUnit(integration)
// 5️⃣ Start the server
httpAdapter.start(port = config.httpPort)
// Optional: add graceful shutdown hook
Runtime.getRuntime().addShutdownHook(Thread {
db.close()
httpAdapter.stop()
})
}Why this order matters
- Deterministic startup – each component receives fully‑initialised dependencies.
- Testability – you can replace any step with a mock (e.g., an in‑memory DB).
- Graceful shutdown – resources are released in reverse order, preventing leaks.
Practical Example
Below is a minimal, end‑to‑end example that you can copy‑paste into a Kotlin project (using Ktor as the HTTP engine).
// ---------- AppConfig.kt ----------
data class AppConfig(
val dbUrl: String,
val dbUser: String,
val dbPassword: String,
val httpPort: Int
) {
companion object {
fun load(): AppConfig = AppConfig(
dbUrl = System.getenv("DB_URL") ?: "jdbc:h2:mem:test",
dbUser = System.getenv("DB_USER") ?: "sa",
dbPassword = System.getenv("DB_PASSWORD") ?: "",
httpPort = System.getenv("HTTP_PORT")?.toIntOrNull() ?: 8080
)
}
}
// ---------- IntegrationUnit.kt ----------
class IntegrationUnit(
private val config: AppConfig,
private val db: Database
) {
fun userRepository(): UserRepository = UserRepositoryImpl(db)
fun userService(): UserService = UserServiceImpl(userRepository())
}
// ---------- HttpAdapterUnit.kt ----------
class HttpAdapterUnit(
private val integration: IntegrationUnit,
private val engine: ApplicationEngine = embeddedServer(Netty, port = integration.config.httpPort)
) {
init {
engine.routing {
get("/users/{id}") {
val id = call.parameters["id"] ?: return@get call.respond(HttpStatusCode.BadRequest)
val user = integration.userService().getUser(id)
call.respond(user)
}
post("/users") {
val request = call.receive<CreateUserRequest>()
val created = integration.userService().createUser(request)
call.respond(HttpStatusCode.Created, created)
}
}
}
fun start() = engine.start(wait = true)
fun stop() = engine.stop(gracePeriodMillis = 1000, timeoutMillis = 5000)
}
// ---------- Main.kt ----------
fun main() {
val config = AppConfig.load()
val db = Database.connect(config.dbUrl, config.dbUser, config.dbPassword)
val integration = IntegrationUnit(config, db)
val httpAdapter = HttpAdapterUnit(integration)
Runtime.getRuntime().addShutdownHook(Thread {
db.close()
httpAdapter.stop()
})
httpAdapter.start()
}What you get
| Feature | Implementation |
|---|---|
| Configuration | AppConfig reads from env vars (fallback defaults). |
| Database | Simple Database wrapper (could be Exposed, JOOQ, etc.). |
| Integration layer | IntegrationUnit wires repository → service. |
| Adapter layer | HttpAdapterUnit uses Ktor routes, delegates to the service. |
| Boot & shutdown | main() orchestrates everything and adds a graceful shutdown hook. |
Key Take‑aways
| ✅ | Take‑away |
|---|---|
| 1️⃣ | Separate concerns – keep business logic (IntegrationUnit) away from transport logic (AdapterUnit). |
| 2️⃣ | Explicit wiring – the boot sequence should clearly show what is created when and why. |
| 3️⃣ | Testability – each unit can be instantiated with mocks, enabling fast unit and integration tests. |
| 4️⃣ | Swap‑ability – you can replace the HTTP adapter with a gRPC or CLI adapter without touching the core. |
| 5️⃣ | Graceful shutdown – always release resources in reverse order of creation. |
References
Original article – “Filling the Unit System – IntegrationUnit, AdapterUnit and the Full Boot Sequence” – Dev.to
https://dev.to/mdreal32/filling-the-unit-system-integrationunit-adapterunit-and-the-full-boot-sequence-25hdClean Architecture – Robert C. Martin, Uncle Bob – concepts of use‑case and interface adapters.
Ktor documentation – https://ktor.io/ – for the HTTP adapter example.
Dependency Injection patterns – Dagger, Koin, Spring – optional frameworks if you prefer annotation‑based wiring.
Feel free to adapt the snippets to your preferred language or framework; the core ideas—clear separation, explicit boot order, and testability—remain the same.
Last post
The Unit System introduced its first real types — RuntimeUnit, PackageManager, and defineRuntime(). The runtime layer is complete, but a runtime by itself does nothing: it knows how to run things, yet nothing is asking it to run anything. The system still needs the other two unit kinds — integrations and adapters — before it can actually connect.
IntegrationUnit
IntegrationUnit is the interface for framework‑specific plugins — React, NestJS, Angular, Docker (as an orchestrator), and anything else that wires into the Velnora lifecycle.
If runtimes are the foundation and adapters are the build tools, integrations are the pieces that actually give a workspace its identity.
Design highlights
All lifecycle hooks are optional
- An integration implements only what it needs.
- If it only needs to expose an API during initialization, it implements
configure(). - If it also needs to run something at build time, it adds
build(). - If it needs neither, it can still exist as a unit that merely declares capabilities for others to depend on.
This is Interface Segregation in practice.
- The kernel duck‑types before calling (e.g.,
'configure' in unit). - Consequently, there is no dead code, no empty method stubs, and no forced implementations.
- The kernel duck‑types before calling (e.g.,
Two Phases, Two Contexts
Every unit kind now gets phased contexts.
| Hook | Context type | Capabilities |
|---|---|---|
configure() | IntegrationConfigureContext | Full access to ctx.expose() and ctx.query() |
build() | IntegrationBuildContext | Query‑only (registration is closed, no new APIs can be exposed) |
Right now all three unit kinds share the same idea, but the context types are not fully separated yet. Runtimes, adapters, and integrations each have different phase‑specific needs, and the concrete context interfaces still need to be split out per kind. That is a task for the next round.
The separation is not just for safety. It makes the intent clear at the type level. If you are inside build(), TypeScript will not even let you call expose(). You know exactly what phase you are in by looking at the context type.
defineIntegration
Just like defineRuntime(), the factory is thin:
defineIntegration(integrationDefinition)- It injects
kind: 'integration'and returns aVelnoraUnit. - The same pattern, same type inference, same generics capturing literal strings from
requiresandcapabilities.
Calling conventions
All three define helpers — defineRuntime(), defineAdapter(), and defineIntegration() — support a second calling convention: a factory function.
defineIntegration((env: ConfigEnv) => ({
// …config that can depend on env.command, env.mode, etc.
}))- Use this when you need to change behavior based on whether you are running
devorbuild. - The factory wraps your function and injects
kindat call‑time.
Both conventions produce the same result: a static definition for simple cases, or a factory for environment‑aware ones. The pattern is identical across all three unit kinds.
Init Order
In theory, integrations should initialize last—after runtimes and adapters. By the time an integration runs its configure() hook, every runtime is booted and every adapter is ready, so the integration can safely query anything it depends on.
Ordering within the integration tier
- A topological sort based on
requiresandoptionalRequiresdetermines the exact order. - Example: if your integration depends on
'docker', the Docker integration will be configured first.
Note: The resolver that performs this sorting is not wired up yet.
Open question: circular dependencies
What happens when A requires B and B requires A?
- The strategy is still undecided.
- Possible approaches:
- Reject the cycle at startup.
- Break the cycle with a warning and continue.
- For now the design assumes no cycles—creating one will lead to undefined behavior. A concrete policy must be defined before the resolver ships.
AdapterUnit — The Middle Layer
After the integrations are in place, the next step is the adapter. AdapterUnit represents build‑tool orchestrators such as Vite, Webpack, Turbopack, esbuild, and similar.
Required hooks
dev(project: Project, ctx: AdapterDevContext): DevServerResult
build(project: Project, ctx: AdapterBuildContext): BuildResult- Both hooks are required.
dev()returns aDevServerResult– connection information that the Host uses to wire up the dev server.
Design shift: removing mode
Earlier iterations exposed a mode: 'thread' | 'process' property on the adapter. That was a mistake.
Whether something runs in‑thread or as a child process is the runtime’s decision, not the adapter’s.
- A Vite adapter should not care if it runs inside Node’s process or is spawned separately.
- It only declares what to run.
- The runtime decides how to run it.
Execution flow
- The adapter queries the runtime via
ctx.query(). - It calls something like
execute(). - The runtime returns a declarative
ExecutionPlan(a discriminated union).
| Mode | What the ExecutionPlan contains |
|---|---|
| Thread | A run function that Velnora invokes in‑process. |
| Process | The binary, arguments, and cwd for Velnora to spawn as a child process. |
Either way, the adapter code stays unchanged, and the same adapter works with Node, Bun, or any other runtime.
Phased contexts
AdapterDevContext– passed todev().AdapterBuildContext– passed tobuild().
Defining an adapter
defineAdapter() follows the same pattern as the other helpers:
- Accepts either a static object or a factory function.
- The
kindfield is injected automatically. - Full type inference is provided for dependencies.
The Full Boot Sequence (In Theory)
Note: The init order described below is not implemented yet. It outlines the intended flow once the resolver is wired up.
- Runtimes boot first.
- Adapters initialize next (making
dev/buildhooks available). - Integrations configure last, after all runtimes and adapters are ready.
Within each tier, a topological sort based on requires / optionalRequires resolves the exact ordering.
When the resolver is in place, the system will:
- Enforce this sequence automatically.
- Detect dependency cycles.
- Provide clear error messages.
How It Should Work
Runtimes – language runtimes boot first
Adapters – build tools next
Integrations – frameworks last
Within each tier, a topological sort by requires and optionalRequires determines the exact ordering. You drop units into one array and the Kernel figures out the rest—that is the design. What actually happens in practice is a different story, but this is the contract the system is built around.
All three unit kinds are now implemented:
| Layer | Units / APIs |
|---|---|
| Runtime | RuntimeUnit, PackageManager, Toolchain, defineRuntime() |
| Adapter | AdapterUnit, the execution‑model split, defineAdapter() |
| Integration | IntegrationUnit, phased contexts, defineIntegration() |
The Unit System is complete—every unit kind has its interface, its factory, and its place in the boot sequence.
A Note on AI and How I Work
I use AI for generating tests and JSDoc, and in rare cases for code. Even when it generates code, I review it multiple times before applying it to the project and committing. The code architecture is mine.
The way the work gets seen—which packages should exist, how the spec should read, how the design should be documented—is where I use Notion AI. Each time I work on a feature, the process takes almost a couple of hours:
- Understand the design.
- Consult with AI.
- Analyze the AI output again.
- Document it properly.
The thinking is mine; the documentation process is a collaboration.
Current limitation:
Almost every code assistant has usage caps—except Notion AI, which I have already exhausted. Consequently, tests and JSDoc generation are temporarily unavailable until I get a local assistant running. I’m setting one up, but it isn’t fully ready yet. Right now I’m creating a connection between my PC and my Mac so I can run it remotely, independent of distance.
What Is Next
The Unit System is complete—every unit kind has its interface, its factory, and its place in the boot sequence. The next step is wiring them into the Kernel’s actual resolver and creating the first real unit packages. This means:
- Real
defineRuntime()calls for Node and Bun. - A real
defineAdapter()for Vite. - A real
defineIntegration()for React.
Interfaces are done; now it’s time to fill them.