ADR-0012: Profile-switched outbound gateways — local dev needs zero external credentials

  • Status: Accepted
  • Date: 2026-06-05
  • Scope: core (any backend talking to external services)

Context

Outbound integrations (email, payments, AI providers, …) tempt two bad setups:
requiring real credentials to boot a dev environment (slow onboarding, secrets
sprawl, accidental real side-effects from a laptop), or if (isDev) branches
inside business logic.

Decision

Every outbound integration is a port (interface) in the application layer
with at least two implementations, selected by Spring profile:

  • the real adapter under @Profile("!dev") — talks to the actual service;
  • a log/no-op adapter under @Profile("dev") — records the call (logs the
    email body, fakes the response) so flows are fully exercisable locally.

Business logic depends only on the port; it cannot tell which adapter is
active. Example (Expenses): EmailGateway with SmtpEmailGateway
(@Profile("!dev")) and LogEmailGateway (@Profile("dev")) — local dev
needs no SMTP credentials, and registration emails print to the console.

Where behavior must differ more richly than swap-the-adapter (e.g. a sandbox
mode of the real service), prefer the service’s own test mode (Stripe test
keys) injected via environment config — still no code branches.

Consequences

  • git clone → run works with no secrets; onboarding is the BOOTSTRAP doc, not
    a credentials scavenger hunt.
  • No accidental real-world side effects from development machines.
  • Each new integration costs one extra (trivial) dev adapter.

Amendment — 2026-09-10: required settings have no default in the base profile

The same split between profiles applies to the settings the app cannot run without: the
datasource, and secrets such as the JWT signing key.

  • The base profile reads them from environment variables with no default
    (url: jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME}). A deployment that forgets
    one fails at startup, instead of reaching for localhost or a known key.
  • The dev profile supplies local defaults that match the compose file
    (${DB_HOST:localhost}), so dev still needs zero configuration.
  • An optional integration may default to blank in the base profile, which leaves its
    feature off. A credential never gets a working default there.

Evidence: both Expenses and dracomania follow this for the datasource, and Expenses also
for JWT_SECRET. One exception found: Expenses’ base application.yml defaults
SWAGGER_USER / SWAGGER_PASSWORD, the Basic-auth credentials guarding its API docs.

Amendment — 2026-09-10: notifications are sent after commit

Mail is a side effect the database cannot roll back. Sent inside the use case’s
transaction, it can announce a change that then rolls back. And a mail failure becomes the
use case’s failure: the change it announced is undone, and the request answers 500. Both
backends met this and settled on the same shape.

A notification whose failure must not change the request’s outcome is sent after
commit.

  • The use case publishes an event, and a @TransactionalEventListener(phase = AFTER_COMMIT) sends the message. The use case knows nothing about mail, and a message
    only goes out for a change that was saved.
  • Nothing about the send reaches the response. The listener either catches and logs its
    own failure (Expenses), or runs @Async, so a failure lands in the async error handler
    (dracomania).
  • Also @Async when the response must not wait on the mail server. On an endpoint that
    must answer alike whether or not an account exists, a synchronous send would make a
    registered address’s answer measurably slower than an unknown one’s.
  • Publish inside the transaction. With none active, a @TransactionalEventListener does
    not run at all (unless it sets fallbackExecution = true), and nothing reports it.
  • A synchronous listener that writes to the database uses REQUIRES_NEW. The
    publishing transaction has already completed, and a write that joins it is never
    committed.

This covers fire-and-forget notifications. A call whose result the request needs, such as
creating a checkout session or asking a model, stays inline, and its failure is the
request’s failure.

Accepted: delivery is at most once. A mail lost after commit is not retried. In
dracomania the player asks again once the cooldown passes; Expenses records every attempt,
failed or not, in email_deliveries, where support can see it. A mail that must never be
lost needs an outbox, and neither project has one.

Evidence: dracomania PasswordResetEmailSender (after commit, @Async); Expenses
UserRegisteredEmailListener, PaymentFailedEmailListener and
AccountDeletionEmailListener (after commit, catch and log). Where a project does not yet
comply is recorded in that project’s own docs.