Skip to content

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.go is the one place allowed to know about everything — it is deliberately the messiest file, by design.