Architecture Overview¶
wrkflw is a Kotlin/JVM Gradle multi-module monorepo built to hexagonal (ports & adapters) architecture. The product is the human-task domain (flows, tasks, assignees, approvals, audit); the durable orchestration substrate (Temporal) and the database (PostgreSQL) are infrastructure behind ports.
The layers¶
flowchart TB
subgraph apps["apps/ (composition roots)"]
api["api-service<br/>Ktor REST + Temporal client + outbox publisher"]
worker["worker-service<br/>Temporal worker host"]
end
subgraph adapters["adapters/"]
rest["rest-api (Ktor)"]
temporal["temporal"]
pg["persistence-postgres (jOOQ)"]
events["eventing-cloudevents"]
end
subgraph application["application/ (use cases)"]
cmd["commands & queries"]
end
subgraph domain["domain/ (pure Kotlin)"]
model["aggregates + state machine"]
ports["ports (interfaces)"]
end
api --> rest & temporal & pg & events
worker --> temporal & pg
rest --> cmd
temporal --> cmd
cmd --> model & ports
pg -.implements.-> ports
temporal -.implements.-> ports
events -.implements.-> ports
Dependencies point inward only. domain depends on nothing; application depends only on
domain; adapters depend on application/domain plus their own infrastructure library; the
two apps are the only modules that wire adapters to ports. A build-time boundary test
(Konsist/ArchUnit) fails the build if domain or application ever gain a Ktor/Temporal/jOOQ/
JDBC/DI dependency.
Runtime flow (document approval)¶
sequenceDiagram
actor User
participant API as api-service (REST)
participant DB as PostgreSQL
participant T as Temporal
participant W as worker-service
User->>API: POST /flows (submit)
API->>DB: create FlowInstance + audit (1 tx)
API->>T: start workflow
T->>W: run flow
W->>DB: activity: create Task (PENDING) + audit
W-->>T: wait for signal
User->>API: POST /tasks/{id}/decision (approve)
API->>DB: record decision, advance state, audit, outbox (1 tx)
API->>T: signal(decision)
T->>W: resume
W->>DB: next Task or terminal + audit
The key split (Approach A): Temporal owns orchestration position; PostgreSQL owns human-task state, business data, and audit. Human steps create DB rows and block on a Temporal signal; REST actions validate domain rules, commit the authoritative DB transition, then signal Temporal. See Temporal integration.
Gradle module layout¶
The monorepo is a single Gradle build. settings.gradle.kts declares every module and includes
the build-logic composite build that supplies convention plugins:
wrkflw/
├── build-logic/ # composite build — convention plugins only
│ └── src/main/kotlin/
│ ├── kotlin-jvm.gradle.kts # Kotlin + serialization + ktlint + detekt + Dokka
│ ├── testing.gradle.kts # JUnit 5 + Kotest + Docker-proxy env var
│ └── ktor-app.gradle.kts # Ktor server deps + Netty + serialization
├── domain/ # pure Kotlin — no project dependencies
├── application/ # depends on :domain only
├── adapters/
│ ├── persistence-postgres/ # depends on :domain, :application + jOOQ/Flyway
│ ├── temporal/ # depends on :domain, :application + Temporal SDK
│ ├── rest-api/ # depends on :domain, :application + Ktor server-core
│ └── eventing-cloudevents/ # depends on :domain, :application + CloudEvents
└── apps/
├── api-service/ # depends on all adapters it needs + Koin Ktor plugin
└── worker-service/ # depends on persistence + temporal adapters + Koin core
Convention plugins replace per-module boilerplate. Every Kotlin module applies
id("kotlin-jvm"), which centrally configures the toolchain (JVM 21), ktlint, detekt, and Dokka.
Modules that have tests add id("testing"); the api-service and worker-service use
id("ktor-app") or the plain application plugin respectively.
The dependency graph is the architecture diagram. Each module's build.gradle.kts lists
exactly which other modules it depends on. Because Gradle enforces these declarations at compile
time, the inward-only rule (constitution Principle I) is structurally impossible to violate — if
domain tried to import from application, the build would fail to resolve the dependency before
any source is compiled. The boundary is also asserted by an ArchUnit test as a second line of
defence (T014).
How each layer declares its dependencies¶
| Module | project(...) dependencies |
Key library dependencies |
|---|---|---|
domain |
— | kotlin-stdlib only |
application |
:domain |
kotlin-stdlib |
adapters:persistence-postgres |
:domain, :application |
jOOQ, Flyway, PostgreSQL JDBC |
adapters:temporal |
:domain, :application |
Temporal SDK |
adapters:rest-api |
:domain, :application |
Ktor server-core (no Netty — transport is the app's choice) |
adapters:eventing-cloudevents |
:domain, :application |
CloudEvents SDK |
apps:api-service |
all adapters above | Koin Ktor plugin, Netty (via ktor-app convention) |
apps:worker-service |
persistence-postgres, temporal |
Koin core |
rest-api depends on Ktor server-core but not on a transport engine. The engine (Netty) is
pulled in by the ktor-app convention plugin applied only in apps:api-service. This means a
future service could swap Netty for CIO by changing one line in its own build file.
jOOQ code generation¶
adapters:persistence-postgres wires Flyway migrations and the jOOQ Gradle plugin together so
that generated Kotlin DSL classes are produced at compile time from the live schema. Generated
sources land in build/generated-src/jooq/main and are added to the main source set
automatically. No checked-in generated files.
Koin dependency wiring¶
Koin is used exclusively in the apps/ layer — domain and application layers never see it.
Each app owns a module function that binds every domain port (interface) to its adapter
implementation for that deployable. There are no annotations; wiring is explicit Kotlin code.
api-service: infraModule()¶
AppModule.kt in apps/api-service defines infraModule(dataSource, temporalHost, temporalPort,
taskQueue). It receives infrastructure config as plain parameters and registers:
DataSource / DSLContext ← JooqDslContextProvider(dataSource)
Clock ← SystemClock (object)
FlowDefinitionRepository ← FlowDefinitionRepositoryPostgres(dslContext)
FlowInstanceRepository ← FlowInstanceRepositoryPostgres(dslContext)
TaskRepository ← TaskRepositoryPostgres(dslContext)
DecisionRepository ← DecisionRepositoryPostgres(dslContext)
AuditLog ← AuditLogPostgres(dslContext)
WorkflowClient ← TemporalWorkerService.createClient(host, port)
WorkflowEngine ← TemporalWorkflowEngine(workflowClient, taskQueue)
SubmitDocumentUseCase ← SubmitDocumentService(repos + engine + audit + clock)
ClaimTaskUseCase ← ClaimTaskService(taskRepo + auditLog + clock)
ReleaseTaskUseCase ← ReleaseTaskService(taskRepo + auditLog + clock)
SubmitDecisionUseCase ← SubmitDecisionService(all repos + engine + audit + clock)
Koin is installed as a Ktor plugin (install(Koin) { modules(infraModule(...)) }) inside
Application.module(). Route handlers resolve use cases via Ktor's by inject() delegate:
val submitDocument: SubmitDocumentUseCase by inject()
routing {
route("/api/v1") {
flowRoutes(submitDocument, taskRepository)
taskRoutes(claimTask, releaseTask, submitDecision)
}
}
Routes receive use-case interfaces, not implementations — they are unaware of Koin.
worker-service: workerModule()¶
WorkerModule.kt in apps/worker-service registers a subset of the bindings (no REST, no
decision use case) plus the Temporal activity implementations:
DataSource / DSLContext ← same as api-service
Clock / repos / AuditLog ← same postgres adapters
WorkflowClient ← TemporalWorkerService.createClient(host, port)
WorkflowEngine ← TemporalWorkflowEngine(workflowClient, taskQueue)
CreateHumanTaskActivity ← CreateHumanTaskActivityImpl(repos + audit + clock)
AdvanceFlowActivity ← AdvanceFlowActivityImpl(flowInstanceRepo)
TemporalWorkerService ← TemporalWorkerService(workflowClient, taskQueue, activities…)
The worker has no Ktor, so Koin is started directly with startKoin { modules(workerModule(...)) }.
The resolved TemporalWorkerService is then started imperatively:
val koin = startKoin { modules(workerModule(...)) }.koin
val workerService = koin.get<TemporalWorkerService>()
workerService.start()
Why the modules are not shared¶
api-service and worker-service intentionally define separate Koin modules rather than sharing
one. The two deployables have different responsibilities: the API needs decision and release use
cases; the worker needs activity implementations. Sharing a single fat module would force each
deployable to instantiate bindings it does not use — including live database pools and Temporal
clients for things it never calls. Explicit, per-app modules keep startup minimal and make each
app's wiring readable in one place.
Why these choices¶
- Temporal, not a hand-built engine — durability, retries, timers, and "wait for a human"
for free; kept swappable behind the
WorkflowEngineport. (ADR-0002) - DB as the task source of truth — work-list queries and admin operations stay simple SQL,
and single-effective-decision is enforced with optimistic concurrency on
(status, version). - Transactional outbox + CloudEvents — state and emitted events never disagree; integration events never drive internal progression (that is Temporal's job).
- Ktor, not Spring — constitution mandate; framework stays in the adapter/app layer only.
Topology¶
Two deployables share domain + application + persistence-postgres:
- api-service — serves REST, sends Temporal signals, runs the outbox publisher.
- worker-service — hosts Temporal workflows and activities.
Both scale horizontally by running more instances. A web frontend is a later phase.