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.
Related
- 0002-clean-architecture-and-use-cases — ports live in application,
adapters in infrastructure
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 forlocalhostor 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
@Asyncwhen 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
@TransactionalEventListenerdoes
not run at all (unless it setsfallbackExecution = 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.