Table of Contents
ADR-0001: Adopt a hexagonal (ports & adapters) architecture¶
- Status: Accepted
- Date: 2026-06-26
- Deciders: Backend team
Context¶
Taskly must support multiple inbound transports (REST today, gRPC and an async
worker later) and several outbound dependencies (PostgreSQL, Redis, JWT, a job
queue, OpenTelemetry). We want the business rules — tenancy, RBAC, token
rotation, cache-aside — to be testable in milliseconds without a database, and we
want to swap infrastructure (e.g. Redis ↔ in-memory, pgx ↔ another driver)
without touching business logic. A conventional layered "handler → service →
repository struct" design tends to leak *sql.DB, *redis.Client, and HTTP
types into the core, making the core slow and awkward to test and coupling
business decisions to library upgrades.
Decision¶
We organize the code as a hexagon (ports & adapters):
internal/domain— pure entities, value objects, business rules, and the ports (interfaces):UserRepository,ProjectRepository,Cache,TokenIssuer,RateLimiter,EventPublisher,TxManager, … It imports no infrastructure.internal/service— application use-cases that depend only on ports. This is where RBAC checks, tenant scoping, cache-aside, and token rotation live.internal/adapter/*— inbound (http) and outbound (postgres,redis,events) adapters that implement or drive the ports.internal/platform/*— cross-cutting connectors and setup (config, logging, metrics, tracing, pool/client factories, crypto).cmd/api— the single composition root that wires concrete adapters into services via constructor injection.
Ports are consumer-defined and small; the Actor (tenant + role) is passed
explicitly into use-cases rather than smuggled through globals.
Consequences¶
Positive
- The core compiles and tests without Docker; the entire service layer is covered by table-driven unit tests against in-memory fakes (sub-second).
- Infrastructure is swappable: the same use-cases run with a Redis cache or no cache, a Redis or in-memory rate limiter, with zero code changes in the core.
- Dependencies point inward only; library churn (pgx, go-redis, otel) cannot ripple into business logic.
Negative / costs
- More indirection and boilerplate (interfaces, constructors, DTO mapping).
- Easy to over-abstract; we keep ports minimal and only introduce one when a second implementation or a test fake actually exists.
Neutral
- The composition root in
cmd/api/main.gois the one place allowed to know about everything — it is deliberately the messiest file, by design.