App in Handy · Case study
A trust platform for the space betweena problem and the app that solves it.
Three repositories, thirteen backend services and one deliberately boring rule: a service owns its data and talks to the rest of the world through an event log. It now runs on a real server, deployed by a pipeline that verifies which build is serving rather than assuming. What follows is how that was built, why each decision went the way it did, and what is still open.
13
services
26
event topics
1,524
backend tests
146
use cases
106
migrations
4
locales
Overview
The product, in sixty seconds
The architecture only makes sense once the product does, so this part comes first, and it stays short.
The premise
Asking whether an app is good is the wrong question. The same tool is excellent for one person’s problem and useless for another’s. So the platform does not rate apps in isolation: it rates the Match (a specific app answering a specific problem), and that pairing is what carries a score.
HandyScore
Every ballot rates several contextual axes from 1 to 10. A deterministic engine normalises them, weighs recent opinions more heavily than old ones without discarding the old ones, pulls thin samples toward a neutral prior, and blends the axes into a single number that ships with its own breakdown. The formula is in the decisions section. Nothing about it is a black box.
Me Too
Problems are voted on separately and much more simply: a net up/down signal that answers "how many people actually have this problem". Demand and quality are different questions, so they are measured with different instruments.
Three repositories
Separate repositories, separate deployment lifecycles, separate commit histories, on purpose. The marketing site should never be able to break the product, and neither should be able to break the fleet.
| Repository | Stack |
|---|---|
| platform13 services, an edge gateway, 9 shared libraries | Java 21 · Spring Boot 3.5 · PostgreSQL · Kafka |
| web-uiThe product interface | Next.js 16 · React 19 · RSC-first |
| landingPublic marketing surface | Next.js 16 · next-intl |
The journey of one request
This is also the map of the rest of the page: each step is expanded somewhere below.
- 01
The edge
The gateway verifies the identity token once, mints a short-lived internal one, applies the rate limit and stamps a correlation id.
- 02
One owning service
The request reaches exactly one service, which is internally hexagonal: infrastructure calls application, application calls a pure domain.
- 03
One local transaction
State change and outbound event are written together. Either both commit or neither does.
- 04
The relay
A scheduled relay claims unpublished rows with SKIP LOCKED, stamps a stable event id and publishes keyed by aggregate id.
- 05
Idempotent consumers
Each interested service applies the event to its own read model, deduplicating by that event id in the same transaction as the effect.
Architecture
One edge, thirteen owners, one log
Select a service to see what it owns and who it talks to. Nothing here is a diagram of an intention: every arrow is a topic that exists in the repository.
Underneath
- PostgreSQL 16
- Kafka / Redpanda
- Redis
- pgvector
- OpenTelemetry Collector
- Tempo
- Prometheus
- Grafana
- Alertmanager
- identity-service: Sole owner of users and the only service that talks to the identity provider: webhook, just-in-time provisioning, and the user snapshot every other service projects.
- catalog-service: Owns apps and the category dimension. Hosts the "orphan killer": when a match is created, the matched app stops being an orphan.
- problem-service: Owns problems and their demand score. Consumer-only: every foreign field it reads is a local projection fed from someone else’s events.
- scoring-service: The deterministic HandyScore engine. A ballot recomputes a score and publishes it; three different bounded contexts cache the result.
- social-service: The hub: matches, their message threads and message votes, plus author badges computed fresh on every read so an unban reverts itself.
- moderation-service: Reports, support tickets, and the fleet-wide publication gate, the queue where every new app, problem and match waits for approval.
- search-service: Pure CQRS read side. It owns no source data at all: the index is built entirely from events, and rebuilding it means replaying the log.
- media-service: A stateless leaf and the only holder of object-storage credentials. Validates type and size, returns URLs, never streams bytes through the application.
- notification-service: The email broadcast rail. Stores no address at all; recipients are pulled from identity at send time and used transiently.
- admin-service: Cross-cutting operator surfaces: dashboard statistics fanned out across services, and a black-box probe of the edge rate limiter.
- ai-service: Retrieval-augmented chat grounded strictly in the platform’s own corpus, match insight cards, an evaluation harness, and a full cost ledger.
- seed-service: Harvests public data, neutralises it and writes it through the owning service’s ordinary write API. It never fabricates a vote.
- billing-service: Subscription lifecycle and the free-premium rail. Ships with its paid surface switched off by default.
Eight rules the build enforces
These are not guidelines. Each one is checked by something that fails a build or is impossible by topology.
| # | Rule |
|---|---|
| 01 | A service owns its data. No other service touches its database: no shared schema, no cross-service SQL, no cross-service foreign key.Database-per-service topology plus an ArchUnit rule on foreign persistence imports |
| 02 | Cross-service means an HTTP call or a Kafka event. Never an in-process call.No shared business jars; the libraries carry only the kernel and the event contracts |
| 03 | No distributed transaction. A unit of work spans exactly one database.Transactions only inside a single service’s interactors; cross-service consistency is eventual |
| 04 | Every service is internally hexagonal, with a domain that has no framework in it.A per-service ArchUnit rule set carried over from the monolith |
| 05 | Events are reliable via the outbox, and consumers are idempotent.Outbox table and relay per producer; deduplication by event id in the consumer’s transaction |
| 06 | Authentication is verified once, at the edge.Gateway JWT filter; services trust only the gateway-minted internal token |
| 07 | Contract-first: the sync API is OpenAPI, the async API is a versioned event schema.A contract test fails the root build if an event record is missing from the catalogue |
| 08 | Everything is observable: trace, metrics and correlation id travel over HTTP and Kafka alike.An auto-configured observability starter; no per-service configuration to forget |
How it got here
Writing microservices from scratch and carving them out of a working monolith are different jobs. This was the second one, and the monolith stayed byte-for-byte frozen the whole way: readable, runnable, never edited.
- Groundwork
Infrastructure before extraction
Broker, database, cache and the observability stack came up locally first. The monolith was declared frozen: read it, copy from it, never change it, so a rollback always remained real.
- Strangler
The gateway takes the front door
Everything was proxied to the monolith. From then on, each extraction added a higher-priority route in front of it. That was the cut-over mechanism: one path at a time, always reversible.
- Platform
The shared rails
Correlation id, verify-once plus internal token minting, the Redis rate limit, the outbox/idempotency/dead-letter library and the OpenTelemetry starter. Deliberately built as auto-configured libraries so no service could forget to opt in.
- Decoupling
Every cross-service read becomes a projection
The real work. One by one, reads that reached into another module’s tables became locally owned projections fed from the event log. The fleet’s first synchronous inter-service client was built with retries and a circuit breaker, then made unnecessary and retired.
- Cleanup
The last shared reads
Seven remaining cross-context reads were closed one at a time, each replaced by a projection owned by the reader. The honest part: an earlier report claimed this was already finished. It was overstated, and the retrospective says so.
- Split
Physical separation, then the catch-all goes
Every stateful service moved into its own database with its own migration chain. Then the monolith’s catch-all route was removed and it left the request path entirely. The binary stayed runnable for reference.
- After
Parity, pipeline, hardening
Around fifty use-case and domain tests were brought back service by service, the CI matrix and release pipeline were built, and the fleet was booted greenfield with observability verified live.
Three things that only show up when you actually do it
Splitting a monolith kills in-process listeners silently
Two behaviours ran on in-process event listeners: an app leaving the "orphan" list when it got matched, and an author’s reputation changing when their message was voted on. The moment those publishers and listeners ended up in different processes, both stopped firing, with no error, no exception, nothing in a log. Both were found and rewired as Kafka consumers. Nothing breaking is not evidence that nothing broke.
A derived flag has to re-emit, or downstream never learns
When an app stopped being an orphan, the flag flipped in the owning service but no event was published, so search’s orphan list was permanently stale. The fix was one line in the right place; finding it took reasoning about which state transitions produce events and which quietly do not.
Auditing your own migration report is part of the migration
A phase report claimed the last shared-database reader had been retired. Seven more existed. That correction is written into the retrospective rather than removed from it, because a wrong claim in a document is a bug in the document.
Event backbone
How a state change becomes everyone else’s truth
Every arrow in the previous section is one of these topics. The chain below is what carries them, and each step exists because of a specific way the previous design could lose data.
The chain
- 01
Interactor changes state
A use case mutates its own aggregate and publishes an in-process domain event. It knows nothing about Kafka.
- 02
Outbox appender, before commit
A listener writes the event into an outbox table inside the same transaction. A rollback publishes nothing; a commit cannot lose the event.
The earlier design published directly after commit. If that send failed, the event vanished with no trace and no compensation: the state was changed and nobody downstream ever heard about it.
- 03
Relay publishes
A scheduled relay claims pending rows with SKIP LOCKED (safe across instances), stamps a stable event id and publishes keyed by aggregate id.
Keying by aggregate id is what gives per-aggregate ordering. There is one deliberate exception, and it is in the notes below.
- 04
The log
Topics are named after the bounded context that owns them. The log is the reason a read model can be rebuilt: replay is a first-class recovery path, not a fantasy.
- 05
Consumers apply it
Each interested service projects the event into its own schema. Deserialisation is guarded, so a malformed payload cannot poison the partition.
- 06
Deduplication in the same transaction
The processed-event marker and the business effect commit together. That is what turns at-least-once delivery into exactly-once effect.
- 07
Bounded retry, then dead-letter
Three backed-off attempts, then the record moves to the topic’s dead-letter queue. A deserialisation failure skips the retries entirely, because retrying a payload that will never parse is just a slower failure.
- 08
Replay on demand
An operator endpoint replays dead-lettered records byte-faithfully, headers included, so the original event id survives and deduplication still protects a partially applied batch.
The catalogue
This registry is not documentation. A contract test discovers every event record in the shared library and fails the root build if it has no row here, so the async API cannot drift away from its description.
- scoring.app-score-updatedSnapshot
Refreshes the app’s cached quality score.
- Producer
- scoring
- Consumers
- catalog, ai
- Key
- appId
- scoring.problem-score-updatedSnapshot
Refreshes the problem’s cached demand score.
- Producer
- scoring
- Consumers
- problem, ai
- Key
- problemId
- scoring.handy-score-updatedSnapshot
Refreshes the match score wherever it is cached.
- Producer
- scoring
- Consumers
- social, problem, search, catalog, ai
- Key
- matchId
- scoring.problem-vote-castSnapshot
Pre-fills the caller’s own "Me Too" verdict.
- Producer
- scoring
- Consumers
- problem
- Key
- problemId:userId
- scoring.problem-vote-clearedTombstone
Drops a withdrawn vote so it stops pre-filling.
- Producer
- scoring
- Consumers
- problem
- Key
- problemId:userId
- scoring.app-vote-castSnapshot
Pre-fills the caller’s own per-axis app rating.
- Producer
- scoring
- Consumers
- catalog
- Key
- appId:userId
- scoring.match-vote-castSnapshot
Pre-fills the caller’s own per-axis match rating.
- Producer
- scoring
- Consumers
- social
- Key
- matchId:userId
- catalog.category-upsertedSnapshot
Upserts the category label consumers render locally.
- Producer
- catalog
- Consumers
- problem, social, search
- Key
- categoryId
- catalog.app-upsertedSnapshot
Upserts the local app projection: label, search text, orphan flag.
- Producer
- catalog
- Consumers
- social, scoring, problem, search, ai
- Key
- appId
- catalog.app-pending-reviewGate
Adds the app to the approval queue.
- Producer
- catalog
- Consumers
- moderation
- Key
- appId
- catalog.app-deletedTombstone
Removes the app from every projection.
- Producer
- catalog
- Consumers
- social, scoring, problem, search, ai
- Key
- appId
- catalog.category-deletedTombstone
Drops the category; labels fall back to uncategorised.
- Producer
- catalog
- Consumers
- problem, social, search
- Key
- categoryId
- identity.user-upsertedSnapshot
Upserts the local user projection: badge, reputation, ban state.
- Producer
- identity
- Consumers
- social, moderation
- Key
- userId
- identity.user-deletedTombstone
Removes the user from every projection.
- Producer
- identity
- Consumers
- social, moderation
- Key
- userId
- social.match-message-postedSnapshot
Snapshots a message so it stays reportable.
- Producer
- social
- Consumers
- moderation
- Key
- messageId
- social.match-message-deletedTombstone
Flags the message deleted but keeps the row as evidence.
- Producer
- social
- Consumers
- moderation
- Key
- messageId
- social.match-createdSnapshot
Upserts the match projection and un-orphans the matched app.
- Producer
- social
- Consumers
- problem, catalog, search, scoring, ai
- Key
- matchId
- social.match-deletedTombstone
Removes the match from every projection.
- Producer
- social
- Consumers
- problem, search, scoring, catalog, ai
- Key
- matchId
- social.match-pending-reviewGate
Adds the match to the approval queue.
- Producer
- social
- Consumers
- moderation
- Key
- matchId
- social.user-reputation-changedDelta
Applies a reputation delta to the message author.
- Producer
- social
- Consumers
- identity
- Key
- userId
- problem.problem-upsertedSnapshot
Upserts the problem projection: searchable text, status, author.
- Producer
- problem
- Consumers
- search, scoring, social, catalog, ai
- Key
- problemId
- problem.problem-deletedTombstone
Removes the problem from every projection.
- Producer
- problem
- Consumers
- search, scoring, social, catalog, ai
- Key
- problemId
- problem.problem-pending-reviewGate
Adds the problem to the approval queue.
- Producer
- problem
- Consumers
- moderation
- Key
- problemId
- catalog.contribution-rewardedDelta
Awards karma once for an accepted app.
- Producer
- catalog
- Consumers
- identity
- Key
- userId
- problem.contribution-rewardedDelta
Awards karma once for an accepted problem.
- Producer
- problem
- Consumers
- identity
- Key
- userId
Four subtleties worth the paragraph
Snapshots forgive, deltas do not
Almost every topic carries the full current state, so a consumer sets a value and a re-delivery simply converges. Two topics carry a change instead: a reputation adjustment and a contribution reward. For those, deduplication is not a nicety; it is the only thing standing between at-least-once delivery and double-counting. The reward path goes further and adds a second, independent guard at the producer: a once-only timestamp on the content itself, so publish → unpublish → republish rewards exactly once.
The same word, two correct behaviours
A deleted app, match or problem is removed from every projection, because a deleted thing must not appear in a list. A deleted thread message is not: its row stays with a deleted flag, because a message that has since been removed must still be reportable and the evidence must survive. Both are called tombstones; treating them the same way would break one of them.
A consumer group is a correctness decision
One service reacts to match creation for two unrelated reasons: it mutates an aggregate, and it maintains a projection. Put both listeners in one consumer group and they split the partitions between them: each reaction sees only some events, and neither is obviously broken. Separate groups make it a clean fan-out where both see everything.
The partition key is not always the aggregate id
Vote events are keyed by the problem-and-voter pair rather than by the vote row. The identity of the read model is that pair, and one person’s vote → change → withdraw sequence must stay ordered. The vote row’s id changes across a withdraw-and-revote, so keying by it would let a stale cast land after the clear that was supposed to remove it.
Engineering decisions
Why each part is the way it is
Each card states the decision and the reason in a few lines. Open one for the full argument, the trade-off it accepted, and where it lives in the repository.
DataA service owns its data: no shared schema, no cross-service join, no cross-service foreign keyForeign data is a soft identifier plus a projection the reader owns and keeps up to date from the event log.A shared table is a shared deployment. The moment two services read the same rows, neither can change its schema alone, and the microservice boundary becomes decoration.
Every stateful service has its own database and its own migration chain, starting from its own first version. Where a service needs another’s data, it holds the identifier and a local read model behind a port it defines itself, so the shape it stores is the shape it needs, not the shape the owner happens to have.
The rule is not maintained by discipline. An ArchUnit rule fails the build if a service imports another’s persistence types, and after the physical split there is no shared schema left to reach into even by accident.
Trade-offEvery cross-service read is eventually consistent. Write-path checks like "does this app exist" become converging rather than certain, which is acceptable here and is written down where it applies rather than discovered later.
In the repository
- 13 × <svc>_db
- ArchUnit: SERVICES_DO_NOT_DEPEND_ON_EACH_OTHER
- *LookupPort → EventFed*Adapter
DataThe read side owns no source data at allSearch has a database, but not a single business table: four projections built purely from events, plus a full-text helper.Search reads across every context, which is exactly the query that tempts you into a join across boundaries. Making it a pure projection removes the temptation and makes rebuilding an index a replay rather than a migration.
Queries run against local projections through plain SQL with Postgres full-text search over a GIN index. There are no ORM entities here on purpose: the read side has no domain to protect, so an object mapper would be pure overhead.
Two rules keep paging honest. Sorting is an enum, and the enum is the allowlist: an unknown value is rejected before any of our code runs, because the clause has to be interpolated into a native query and a client string must never reach it. And every ordering ends with the row id, because without a strict total order rows that tie on the sort column silently repeat or vanish between pages.
Trade-offAnything you want to search must first travel on an event. There is no convenient join to reach for when a new field is needed, which is the point, but it does mean a search feature sometimes starts in another service.
-- Every ORDER BY on the paged union ends with the id.
-- Without a strict total order, rows tying on the sort column
-- silently repeat or vanish between pages.
ORDER BY ts_rank(search_vector, query) DESC, created_at DESC, idIn the repository
- search_db
- app/problem/category/match_projection
- SearchRepositoryAdapter
- SearchSort
DataA multi-axis listing is one composite query, never a precedence chainEvery filter is a null-tolerant predicate in the same query. Adding one is adding a line, not a branch.The listing used to be an if/else ladder over the filter axes: the first non-null one won and the rest were silently dropped. A request filtering by name and by orphan status answered only the name half, and no caller could tell.
Collapsing it into a single criteria object and a single native query deleted nine now-dead repository methods and made the endpoint behave the way its own documentation always claimed it did.
The JSON attributes are queried by whole-document containment rather than field extraction (extraction cannot answer array membership at all), which also means one GIN index serves every JSON axis, present and future.
Trade-offOne larger query is harder to read at a glance than five small methods. In exchange, it is impossible for two filters to disagree about which one wins.
-- One query, every axis null-tolerant. A new filter is a predicate here,
-- never a new repository method and never a new branch.
WHERE (CAST(:categoryId AS uuid) IS NULL OR a.category_id = CAST(:categoryId AS uuid))
AND (CAST(:platform AS text) IS NULL OR a.metadata @> :platformJson::jsonb)
AND (CAST(:name AS text) IS NULL OR a.search_vector @@ plainto_tsquery(:name))In the repository
- AppSearchCriteria
- AppSearchNativeQuery
- GIN jsonb_path_ops (V6)
DataA fleet-wide mapping default that quietly made a field unclearableIgnoring nulls is right for "do not blank what the caller did not send" and wrong for a field whose absence is the new value.Once a screenshot had been set on a problem it could never be taken back down. The domain said null, the mapper ignored null, the old URL survived every save, and nothing failed.
The fix is a per-property override on that one field, not a change to the global default, which is correct for everything else. Absence also got one representation: blank input is normalised to null in the command itself, so no reader ever has to test for two kinds of "no picture".
The part worth remembering is how it was caught. Mock-based tests passed either way: they were asserting on a mapper that was doing exactly what it was told. It took an integration test against a real database to show that the row never changed.
Trade-offEvery future optional-and-removable field needs the same explicit override, and a test that actually clears it. That is a small recurring cost, accepted because the alternative is a class of silent data bug.
// IGNORE is right for "don't blank what the caller didn't send" and
// silently wrong for a field whose absence IS the new value.
@Mapping(target = "screenshotUrl", source = "screenshotUrl",
nullValuePropertyMappingStrategy = SET_TO_NULL)
void updateEntity(Problem domain, @MappingTarget ProblemEntity entity);In the repository
- MapStructGlobalConfig
- problems.screenshot_url (V5)
- ProblemPersistenceIT
MessagingState and event commit in the same transactionThe outgoing event is written to an outbox table inside the business transaction. A relay publishes it afterwards.The previous design published immediately after commit. If that publish failed (broker down, network blip, process killed), the state change survived and the event did not. Nobody downstream ever learned, and nothing recorded that anything was lost.
A listener that runs before commit appends the event to the owning service’s outbox on the same connection. Rollback publishes nothing. Commit makes the event as durable as the data it describes.
A scheduled relay claims pending rows with SKIP LOCKED, which makes it safe to run several instances, stamps a stable event id used later for deduplication, and publishes keyed by the aggregate id so one aggregate’s events stay ordered.
Trade-offPublication is now asynchronous and slightly delayed, and the relay’s polling interval becomes a real latency knob. Change-data-capture would remove the polling; it is a recorded, deliberate deferral rather than an oversight.
// BEFORE_COMMIT: the event row joins the business transaction, so a
// rollback publishes nothing and a commit can never lose the event.
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
void on(AppScoreUpdatedEvent event) {
outbox.append(ScoringTopics.APP_SCORE_UPDATED, event.appId(), event);
}In the repository
- OutboxWriter
- OutboxRelay
- <svc>.outbox_events
- ScoreOutboxAppender
MessagingAt-least-once delivery, exactly-once effectThe deduplication marker is written in the same transaction as the business effect. Not before it, not after it.Any broker will redeliver. If the marker commits separately, there is a window where the effect applied and the marker did not, and the next delivery applies it again.
For snapshot events this is a convenience: a consumer sets a value, so a replay converges anyway. For the two topics that carry a change rather than a state (a reputation adjustment, a contribution reward), it is the only thing preventing double-counting.
The reward path carries a second, independent guard at the producer: a once-only timestamp on the content itself. Consumer deduplication stops a redelivery; the producer stamp stops a publish → unpublish → republish cycle from rewarding twice. Two layers because a delta genuinely needs both.
Trade-offEvery consumer pays a small write per event, and the ledger has to be pruned eventually. Cheap, next to the class of bug it removes.
-- The marker is written in the same transaction as the business effect,
-- which is what turns at-least-once delivery into exactly-once effect.
INSERT INTO processed_events (event_id, consumer)
VALUES (:eventId, :consumer)
ON CONFLICT (event_id, consumer) DO NOTHING;In the repository
- ProcessedEventStore
- <svc>.processed_events
- apps.contribution_rewarded_at
MessagingA poison message stops one record, not the systemBounded retries, then a dead-letter topic, and an operator endpoint that replays those records byte-faithfully.Without this, one malformed payload blocks its partition forever and takes a whole projection offline. With naive infinite retry, it does so loudly and expensively.
A single error handler serves the whole fleet: three backed-off attempts, then the dead-letter topic. Deserialisation failures skip the retries entirely, because retrying a payload that will never parse is just a slower failure.
Recovery matters as much as containment. The replay endpoint preserves headers, which means the original event id survives, so replaying a batch that was half-applied is safe, because deduplication still recognises what already landed.
Trade-offRetries are blocking rather than routed through retry topics. For low-volume idempotent consumers that is simpler and adequate; the upgrade path is written down for the day the volume argues otherwise.
In the repository
- ConsumerErrorHandlingAutoConfiguration
- DltReplayer
- POST /actuator/dltreplay
MessagingBuild the resilient synchronous client, then make it unnecessaryThe fleet’s first cross-service call was hardened properly, then replaced by an event-fed projection, and the last synchronous inter-service client retired.A synchronous call couples availability: if the callee is down, the caller is degraded. That is sometimes the right trade, but for a read that could be projected, it is a permanent tax.
The first call was built the careful way (typed HTTP interface, transport timeouts, retry, circuit breaker, bulkhead, an anti-corruption mapper and a graceful fallback), and its full breaker lifecycle was proven under a real outage of the dependency.
Then the owner started publishing the same data as an event, two consumers projected it locally, and the client was deleted. The transport library and the internal endpoint were kept rather than removed: they have no consumers today, and they are exactly what the next genuine synchronous need will use. The same stack now carries the moderation service’s publish calls.
Trade-offThe projected read is eventually consistent, so a write-path existence check can briefly disagree with reality. Accepted knowingly, and the affected paths are documented.
In the repository
- libs/platform-http
- PlatformHttpClientFactory
- AppUpsertedEvent
- social/scoring app_projection
SecurityAuthentication happens once, at the edgeThe gateway verifies the external token, then replaces it with a short-lived internal one that downstream services trust.Thirteen services independently validating an external identity provider is thirteen places to misconfigure, thirteen outbound dependencies, and thirteen different answers to "what is this user allowed to do".
Downstream services never see the external provider. A single decoder bean in a shared starter switched all of them over at once, with no per-service code. The internal token lives two minutes, which is short enough that a leaked one is close to worthless.
The subtle part is the role. It is not copied from the external token: the gateway resolves it from the identity service’s own database, briefly cached, so a permission granted inside the product takes effect across the fleet within that window instead of on the user’s next login.
Trade-offThe cost is availability. When identity cannot answer, the edge refuses the request with a 503 instead of trusting the role the external token claims; an earlier version fell open there, and a resilience campaign closed it. Anonymous traffic is untouched and a thirty-second cache carries known users through a blip, so what is actually refused is an authenticated request for someone nobody has seen recently.
platform:
security:
internal-jwt:
issuer: api-gateway
ttl-seconds: 120 # short enough that a stolen token is worthless
role-cache:
ttl-seconds: 30 # role comes from identity's DB, not from ClerkIn the repository
- GatewaySecurityConfig
- InternalIdentityMintGlobalFilter
- GatewayRoleResolver
- platform-security-starter
SecurityPremium has exactly one derivationOne method decides it, and an administrator is always premium. There is no second flag to keep in sync.Entitlement logic duplicated in three services becomes three subtly different answers, and the bug surfaces as "this one screen thinks I am not a subscriber".
Propagation is derive-only: no new event, no read model, no synchronous call. The identity service exposes the derived value, the gateway stamps it as a claim while minting the internal token, and every service reads the claim. Adding a premium-gated feature is reading a boolean.
Entitlement itself is the union of a user’s active grants: a paid subscription, a promotional code, an administrative complimentary grant. A lapsing subscription therefore never downgrades someone who still holds a valid promotion, because the question "is this user entitled" is asked in one place and answered from all the reasons at once.
Trade-offThe claim is only as fresh as the cache window. That is a deliberate choice: a round trip to identity on every request at the edge is a much worse deal.
// The one and only derivation of "premium" in the fleet.
// An ADMIN is always premium: no second flag to keep in sync.
public boolean isPremium() {
return accountType == AccountType.PREMIUM || role == Role.ADMIN;
}In the repository
- User.isPremium()
- InternalPrincipalView
- EntitlementPolicy.isEntitled
SecurityThe money surface ships switched offOne flag, defaulting to off, closes the entire paid surface at the security layer and again inside the use case.Payments were deferred for a reason outside the code: the provider only onboards a registered company, and there is not one yet. Deleting working code would have been the wrong response; leaving it reachable would have been worse.
While the flag is off, checkout, the subscription lifecycle, the payment ledger, the plan list, the provider webhook and the administrative refund surface are all unreachable for everyone: user, administrator, anonymous alike. Two layers on one flag: the security configuration denies the routes, and the checkout use case refuses in-process, so no charge can be started even from inside the process.
The rest of the design assumes it will be turned on one day. No card data is ever stored; the hosted checkout form holds it. The amount is always resolved server-side from a plan identifier, never accepted from the client. Access is granted only from webhook-confirmed state, because a browser callback can be abandoned or forged and is not authority.
Trade-offCode that does not run does not get exercised. The switch order is therefore covered by a test of its own, so the outermost gate cannot rot while it waits.
In the repository
- BillingSecurityConfig.PAID_SURFACE
- StartCheckoutInteractor
- BillingKillSwitchSecurityIT
SecurityA check written where a secret is used guards only that secretOne registry names every secret the fleet must not boot without, and a single configuration hook refuses a production start before the application context exists.The codebase already knew this pattern and had hand-written it into four beans. It still had a hundred and forty-four configuration keys with no guard at all, because a check placed inside the bean that consumes a secret protects exactly one key. Which keys were protected had become a record of whose bean somebody happened to edit that day.
Where a rule like this lives decides how far it reaches. The only library all fourteen bootable modules already shared was the observability one, and the security starter cannot be added to the gateway at all: it drags in the servlet stack, and a reactive gateway refuses to start when it finds that on the classpath. Opening a new module was better than hiding a security rule inside a jar named for something else.
The sharpest finding was a committed default that worked. The identity provider’s key endpoint defaulted to a live development tenant, so forgetting to set it in production would not fail: it would quietly accept that tenant’s signatures, making anyone who had signed up there a valid user here. When a default is unavoidable, choose one that cannot work — a reserved invalid host — over one that works against the wrong thing.
Trade-offFourteen modules now refuse to start on a missing key, which is a worse first deployment and a better second year. The failure is loud, immediate, and names the key.
// The secret is registered once, centrally, with the consequence of
// forgetting it. Not a check inside the bean that happens to use it:
// that version only ever guards the one key someone remembered.
GuardedSecret.fleetWide(
"SENTRY_DSN",
List.of("sentry.dsn", "SENTRY_DSN"),
Set.of(),
"production exceptions reach nobody — they go to container stdout"
+ " and are lost on the next restart, with no symptom anywhere"),In the repository
- libs/platform-config-guard
- PlatformSecretRegistry
- EnvironmentPostProcessor
- security-gates.sh
SecurityBeing current is not the same as being scannedA bill of materials is generated and scanned on a schedule, image bases are pinned, and every suppression carries an expiry the scanner itself enforces.This phase was planned expecting to find nothing: every framework was on its latest release. The first scan returned forty-six high or critical advisories, six of them critical and three directly in the request path, including a maximum-severity expression-language flaw in the gateway, which is the fleet’s only front door. A version is clean on the day it is chosen. The advisory is published later, and nothing in the repository changes when it is.
Two dependency overrides survive, and each carries its removal condition in a comment rather than in someone’s memory: delete it the day a scan is green without it. Suppressions are held to the same standard. The file requires a statement and an expiry date, and the scanner enforces the date, so an entry stops silencing anything when it lapses and the gate goes red on its own.
Build inputs are dependencies too. A workflow action tag is a movable pointer: if it moves, different code runs against our checkout with our token, and nothing in our diff changes, so actions are pinned by commit. The build wrapper verifies the archive it downloads by hash, because transport security authenticates the server and not the artefact. Image bases are pinned as well, without which scanning them means nothing: one machine had quietly accumulated seven builder images and five run images, and nothing recorded which one any given build had used.
Trade-offOne scanner rather than two. Two would have meant two vulnerability databases, two thresholds and two suppression files to keep in agreement — exactly the divergence this codebase keeps paying for elsewhere, imported into the supply chain.
# An empty bill of materials scans perfectly clean, and a clean scan is
# indistinguishable from a good one by exit code alone. Anything that
# narrows the reactor lands here, so the floor is asserted first.
components=$(jq '.components | length' "$SBOM")
if [ "$components" -lt "$MIN_COMPONENTS" ]; then
fail "SBOM has $components components (< $MIN_COMPONENTS) — scan not trustworthy"
fiIn the repository
- dependency-scan.sh
- CycloneDX SBOM → Trivy
- .trivyignore.yaml (expired_at)
- supply-chain.yml (weekly)
SecurityWho may read this was written down; when it disappears was notEvery user-keyed column is sorted into one of four retention classes, and deletion reaches the second copy as well as the first.Access control answers who can see something. None of it answers when the thing stops existing, and that gap does not look like a permissions bug, so it never surfaces in a permissions review.
The account-deletion event had two consumers and both did the same small thing: drop a projected display name. The name vanished and the content did not, so every assistant conversation, with its full prompt and full answer, stayed keyed to the deleted user indefinitely. Deletion now reaches the content, and it reaches the duplicate first: an evaluation record embeds the same material verbatim and has no user column of its own, so it has to go inside the same transaction and before the row that joins it.
Deleting is not the right answer for every class, and saying which is the point. A private conversation is deleted. A public contribution stays and is de-identified when read, because removing it rewrites other people’s pages. A vote stays, because it is an input to a number that has already been published. Payment records stay because the law requires it. Separately, a log line is a second copy with no owner, no retention and no way to honour a deletion request, so what travels there is the pseudonymous identifier — and the worst offender was the default: a stand-in email sender logged one line per recipient, so every broadcast wrote the user directory into the log.
Trade-offDe-identifying at read time costs a lookup on paths that used to join a name directly. It is the only version that leaves everyone else’s history intact.
In the repository
- identity.user-deleted
- chat_turn_log
- judge_evaluation.full_prompt
- SECURITY_RUNBOOK §6
ArchitectureAn unrun check is not a checkThe last phase of the security campaign audited the running system rather than the source, and found four more defects — including that the fleet was five phases behind the code.Nine phases had verified the repository. Not one of them had asked the running fleet anything. The distinction sounds pedantic right up until it costs something.
The first live measurement looked like a regression: an anonymous health request returned a full component breakdown, a leak that had been closed days earlier. It had not regressed. The images were tagged two hours before that fix landed, and none of the five phases after it existed in any container, while every check in the source tree was green. An image repository goes stale silently under a moving tag, so every service now stamps its build and a check refuses a fleet older than the code it claims to run.
Two suites were red or absent and nobody could have known, because nothing ran them. The frontend header suite had neither a script nor a workflow. The deep end-to-end tier triggers only on a release tag, and this repository has never had one; its first real run against a live fleet found two fresh defects that reading the source could not have found, because in both cases the source looks right. A catch-all handler was turning framework-level bad-request exceptions into server errors from an anonymous endpoint, and the one module that cannot inherit the shared error contract turned out to be the internet-facing one.
Trade-offTesting the running system is slow, needs containers, and cannot run on every commit. So it is a tier with its own trigger — and the lesson recorded beside it is that a trigger nobody pulls is the same as no test.
# Ask the running fleet what it is, rather than assuming it is the repo.
# The first time this was asked, the answer was five phases old: every
# check in the source tree was green and none of it was deployed.
built=$(curl -fsS "$svc/actuator/info" | jq -r '.build.time')
[ "$built" \> "$LAST_FIX" ] || fail "$svc image predates the fix ($built)"In the repository
- fleet-build-check.sh
- /actuator/info build stamp
- fleet-e2e.sh negative tier
- ActuatorExposureIT
ArchitectureThirteen services with the same interiorInfrastructure depends on application, application on a pure domain, and never the other way. One use case is one interactor and one command.Distributed systems get their reputation from the space between services, but most of the confusion actually lives inside them. Making all thirteen interiors identical means learning one service teaches you all of them.
The domain is plain Java with no framework in it, which is what makes the score engine testable as arithmetic rather than as a Spring context. The persistence model is separate from the domain model on purpose: an ORM annotation is a storage concern and has no business shaping a business rule.
Transaction boundaries live only in interactors, errors are RFC 7807 problem documents, and each service owns its own migration chain. The layering is checked by ArchUnit rather than by review, so it stays true on a tired Friday.
Trade-offMore files per feature than a pragmatic three-layer service. Worth it at thirteen services; likely over-built at one.
In the repository
- *Interactor per use case
- ArchUnit rule sets
- platform-kernel
- RFC 7807
ArchitectureEverything is born invisibleNew apps, problems and matches all start pending review and reach the public only through one approval queue.A trust platform where anything appears instantly has no trust in it. But adding a second "published" flag next to an existing status lifecycle creates two sources of visibility truth, and they drift.
So there is only one gate: the existing status lifecycle answers "is this publicly visible". What changed is when events fire. The public streams no longer publish on creation, when the entity is not public yet, but on approval, and unpublishing emits the removal tombstone instead. Downstream consumers added no filter at all; they simply now receive events only for things the public can see.
That shift exposed a whole bug class. Every write path that changes visibility has to emit the event, and one administrative endpoint did not, so an item rejected through it stayed live in the search index and in the assistant’s corpus. The fix emits on the visibility transition itself rather than trusting each caller to remember.
Trade-offApproval is a human bottleneck, and bulk actions had to be built for it, with honest partial-success reporting, because a batch that reports "done" when eleven of twelve succeeded is worse than no report.
In the repository
- AppStatus.isPubliclyVisible()
- PendingItem
- *-pending-review topics
- BulkProgressStream
ArchitectureA test tier that breaks the infrastructure on purposeReal containers, real broker, plus a network proxy the test can cut, with the system under test on one side and the test’s own client on the other.Every guarantee in this system (outbox durability, deduplication, dead-lettering) is a claim about what happens during a failure. Unit tests assert the happy path of each piece; they cannot tell you the chain holds.
The asymmetry is the whole design. The application reaches the broker and the database through the proxy; the test’s own consumer connects directly. So a test can take Kafka away from the application, watch the outbox accumulate with its retry counts, restore the broker and assert that it drains, all while its own verification path stays alive.
One non-obvious detail makes it work at all. A Kafka client bootstraps against one address and is then handed the broker’s advertised address, which it connects to directly, so proxying only the bootstrap address proxies nothing. The broker is therefore started advertising the proxy’s own host port, which puts both bootstrap and data path through it. Without that, cutting the proxy cuts nothing and the test passes for the wrong reason.
Trade-offThese tests deliberately spend real seconds inside outages, so the tier is tagged and excluded from the default build. The fast inner loop stays fast; chaos runs under its own profile.
// The system under test reaches Kafka through the proxy; the test's own
// consumer does not. Without that asymmetry, cutting the broker would
// blind the assertions as well as the application.
registry.add("spring.kafka.bootstrap-servers", ChaosIntegrationTest::proxiedBootstrapServers);
// …and the broker advertises Toxiproxy's host port, or only bootstrap
// would be proxied and cutBroker() would cut nothing.In the repository
- ChaosIntegrationTest
- Toxiproxy 2.12
- OutboxOutageChaosIT
- mvn -Pchaos verify
Applied AIAn assistant grounded only in the platform’s own corpusVector search over content the service projects from the event log, behind a provider-neutral port with a one-line model switch.An assistant on a trust platform that answers from general world knowledge is worse than no assistant: it sounds authoritative about things the platform cannot stand behind.
The corpus is the app, problem and match triangle, projected into the AI service from the same events every other service consumes. Retrieval is approximate nearest neighbour over multilingual embeddings, with the index tuned so recall does not quietly collapse when filters narrow the candidate set.
The application layer never knows which provider is answering. Two lessons are baked into that boundary. Asking a model to "return JSON" is not a contract: it wraps output in a code fence and the parse fails; prefilling the opening brace passes the first test and then dies on an unescaped quote inside a string value, a failure prefill structurally cannot prevent. The permanent fix is constrained decoding against a schema, which makes invalid output unrepresentable rather than merely discouraged.
Trade-offGrounding strictly in our own content means the assistant will say it does not know rather than guess. That is the correct behaviour here and it is still, occasionally, a worse demo.
In the repository
- pgvector vector(1024) · BGE-M3
- HNSW vector_cosine_ops
- LlmPort
- AnthropicChatClient
Applied AIThe ledger is the counterFour spend limits run before any model call, and usage is summed from the record of what was actually billed, never from a separate counter.A second counter alongside the ledger is a second thing that can be wrong, and the two will drift. The only number that cannot lie about spend is the one derived from the spend records themselves.
Four layers run in order at the top of every spending use case: a rolling short window, a weekly ceiling, a global daily cap and a cumulative fuse. Reads fail closed, so the wallet never opens because a database was briefly unreachable, and a window only opens on a request that passed every layer, so a refusal never starts someone’s clock.
Cost is computed in exactly one place, the provider adapter, and a model with no price entry refuses to boot: something whose cost cannot be computed must never serve traffic. Separately, an expensive automated path was not gated but removed: the safest budget guard is not having an unattended spender.
Trade-offSumming a ledger on every request costs a query. It is cheap next to the model call it protects, and it is the only version of the number that is true.
In the repository
- BudgetGuard
- chat_turn_log
- ai.usage_window
- insight_run_log
Applied AIAutomation may create content, never a trust signalThe seeding service writes apps and problems through the owning service’s ordinary API, and never casts a vote.On a platform whose entire output is a score, a fabricated score is not a shortcut; it is a lie about the only thing the product sells.
Harvested public data is normalised into neutral platform content and written through the owner’s normal write API, where it lands pending review like anything else. The seeder does not reimplement moderation; it feeds it. Re-run safety comes from a provenance ledger keyed by source and external id, not from event deduplication: it consumes no events and produces none.
It also has no public surface at all: no gateway route, no external API, only a network-isolated operator endpoint. A component that writes on everyone else’s behalf should be reachable by as few people as possible.
Trade-offA seeded catalogue with no scores looks emptier than one with invented ones. That is the honest state of a platform before it has users, and showing it is the point.
In the repository
- seed-service (8093)
- provenance (source, external_id)
- no /api/v1 surface
ProductThe score is a formula, not a feelingPure arithmetic in the domain layer: normalise, decay by age, regularise toward a prior, blend by axis weight.A rating people are asked to trust has to be explainable. If nobody can say why a match scores 7.4, the number is decoration.
Three choices carry it. A Bayesian prior pulls thin samples toward the middle, so three enthusiastic ballots do not produce a perfect ten. Recency decay weighs old opinions less, because software rots, but it has a floor, because an old assessment is not a worthless one. And the per-axis breakdown is published alongside the headline, because one number never answers "why".
The engine takes ballots and a timestamp and returns a result. No repository, no clock, no configuration lookup, which means it is tested as arithmetic, and the same inputs always produce the same output. The scores that other services cache arrive as events carrying the computed value, so a redelivery converges instead of accumulating.
Trade-offRegularisation makes new matches look unremarkable for a while. That is honest: a score from three ballots should not look like a score from three hundred.
q = (rating − 1) / 9 // 1–10 ballot → [0,1]
decay(d) = 0.75 + 0.25 · σ(0.005 · (730 − d)) // sigmoid recency, 75% floor
axisQ = (Σ decay·q + k·0.5) / (Σ decay + k) // Bayesian prior, k = 3
score = 10 · Σ(wᵢ · axisQᵢ) / Σ wᵢ // weighted blend → 0–10
confidence = LOW (<5) · MEDIUM (<30) · HIGH (≥30) ballotsIn the repository
- HandyScoreCalculator
- pure domain, no I/O
- HandyScoreCalculatorTest
Frontend
The other half of the system
Thirteen backend services are only useful if something in front of them stays coherent. The interface is built with the same instinct: rules that fail a build rather than rules people are asked to remember.
Server-first, with a thin routing layer
Everything is a server component by default; the client directive appears only where interactivity genuinely requires it. The routing layer stays deliberately thin: it reads parameters and metadata and hands off to a view that orchestrates the page. That separation is enforced: a rule fails the build if the routing layer imports a service.
A four-tier data layer
With thirteen services behind it, a contract change must touch exactly one file. Each tier may only talk to the next one down, and skipping a tier breaks the build rather than a review.
- 1. Endpoints
Raw URLs, declared once per domain.
- 2. API
The only layer allowed to touch a network client.
- 3. Services
Anti-corruption: external shapes become internal models, errors are normalised.
- 4. Consumers
Server actions and query hooks. They never see a URL.
Five architectural rules, checked on every run
- 01
No module reaches into another module’s internals, only through its public entry point
- 02
No consumer bypasses the service layer
- 03
No component bypasses the API layer to fetch directly
- 04
No circular dependencies between modules
- 05
The routing layer imports no service or server module
Four languages, zero hardcoded strings
Four locales at full parity, with over twelve hundred keys each. Parity is not a promise: two audit scripts compare the dictionaries and scan for hardcoded text, and both run inside the validation command. Adding an untranslated string fails the build.
Testing and accessibility
Tests are colocated with what they test and run against mocked network handlers rather than a live backend, so they are deterministic without being fictional. Accessibility is asserted in the same suite rather than audited afterwards, and the whole suite runs with the React compiler enabled, the way the application actually ships.
Three things that cost me a day each
A form that dies silently under the compiler
Resetting a form inside an effect stops working once the compiler is enabled: no error, the form simply goes inert. Seeding default values and revalidating instead is the correct pattern, and it is now written down where the next person will look.
An async identity provider produces a permanent wrong answer
Authentication loads asynchronously. A per-user read fired before it is ready returns empty and gets cached as the truth. The screen is not broken, it is confidently wrong. Gating the read on the loaded state is one line; noticing the bug is the hard part.
Measure before blaming the tool
The development server’s memory grew until it died, which looked exactly like a leak in our code. It was an unbounded compilation cache upstream, affecting only development. Documenting it with the evidence and the interim workaround was worth more than a guess would have been.
Quality & pipeline
What has to pass before anything merges
Every rule described on this page is only real because something refuses to build when it is violated. This is that machinery.
Four test tiers
| Tier | What it covers |
|---|---|
| Offline guards | Architecture rules, domain and use-case tests, contract golden and round-trip checksThe default test phase |
| Integration | Real database and broker: outbox round-trips, persistence, native queries, dead-letter replayThe verify phase |
| Chaos | Injected failure: broker outage, database outage, latencyIts own profile, excluded from the default build |
| End to end | The fleet booted, health-gated, then exercised through the gatewayOn demand and on release tags |
Continuous integration
A path-filtered dynamic matrix
A change to one service builds that service and its upstream libraries. A change to a shared library builds everything. A documentation-only change builds nothing. Failures do not cancel their siblings, so one broken service does not hide another.
Release tags
A version tag builds ten container images with buildpacks (no hand-written Dockerfile anywhere in the repository) and runs the end-to-end tier against them.
One local command for the whole thing
A four-stage pipeline: format check, then the entire reactor’s tests, then ten container images, then the fleet brought up with those fresh images and health-gated until every service reports ready. It leaves the fleet running and current rather than just reporting success.
The frontend gate
Format, lint, architecture rules, UI consistency audit, two internationalisation audits, type check and the test suite: one command, and it is the same one the pipeline runs.
The documentation is a test
A contract test discovers every event record in the shared library and fails the root build if it is missing from the event catalogue. Alongside it: serialisation golden files, round-trip checks and an orphan check. The async API therefore cannot drift away from the document that describes it, which is the only version of "documentation stays current" that has ever worked.
Observability, and two bugs it caught
Traces, metrics and logs leave every service through one auto-configured starter, and the correlation id travels over HTTP and Kafka headers alike, so a request can be followed across a broker hop. Service objectives sit alongside messaging and security rules, alerts reach a real destination, and a separate sink receives faults — bound to the error log level, so the decision about what counts as a fault is made once, in the exception handler, rather than a second time by a vendor library.
It earned its keep twice. Percentile latencies were unusable across the fleet because histogram buckets were not being emitted at all; the fix went out to all ten images, after which every service reported a real p99 instead of nothing, and a bounded load test confirmed the edge limiter sheds excess load with no server errors. Later the alert pipeline turned out to be healthy end to end and delivering to nobody: a null receiver accepts every notification and discards it, which is indistinguishable from having nothing to report. It is gone from the file rather than documented as forbidden, so a route pointing at it now fails configuration validation.
Delivery
From a push to a fleet that proves which build it is
The interesting part of a deployment is not that it succeeds. It is whether a successful run and a run that did nothing look different. Everything below exists because, at least once, they did not.
What a push to the development branch does
- 01
A change map decides what is rebuilt
One file owns the answer to "which of the fourteen modules did this commit touch", and both the integration workflow and the deployment read it. A module missing from that map is not rejected — it is silently skipped, and a skipped job reports green, so the map is treated as a rule rather than a convenience.
- 02
Only the changed modules become images
Images are built with buildpacks; there is no hand-written container file for any backend module. Each one is tagged with the commit, not only with a moving label, because a registry tag that moves is a fleet that ages without anything in the repository changing.
- 03
The composition is applied, not the difference
The server pulls and recreates only the services that changed, then sweeps the composition for anything missing without touching what already runs. The first deployment proved why: a delta cannot answer "what is absent on the target", only "what changed since last time".
- 04
A health gate, not a hope
Every service has to report ready before the run is allowed to continue. The ceiling is sized for the binding case — fourteen services and eleven schema migrations starting cold on four cores — because failing long only reports late, while failing short calls a healthy fleet broken.
- 05
The fleet is asked which build it is
Each module publishes a build stamp and the deployment compares it against what the pipeline just produced. This is the step that exists because of the worst finding of the whole project: for five phases the running fleet was older than the code, and every check on the source side was green. A deployment that cannot be dated is a deployment that has not been verified.
- 06
And whether anything is stuck
Finally the outbox is checked for rows that were never published. A broken message pipeline looks exactly like an idle one — health stays green, no errors appear, and the queue simply stops moving — so the only honest signal is the count of unpublished rows.
The box, and the way in
One small server runs the whole fleet. Nothing reaches it from the internet: an outbound-only tunnel connects to the edge network, the gateway listens on loopback, and the firewall stays closed except for administration. The deployment key is read-only and scoped to one repository rather than an account-wide token, on the same principle: a credential should be able to do the job and nothing beyond it.
Two of the decisions here were measurements, not preferences. The subdomain had to lose a label because a wildcard certificate matches exactly one, so the original name could not complete a handshake at all — a failure that looks like an outdated protocol setting and is not. And the number of proxies to trust was read off a throwaway echo container before anything was configured, because that constant decides whether anonymous rate limiting counts a caller or counts the whole internet as one.
What the first real runs found
A pipeline that has never executed has never been verified. Three findings from the first runs, none of which a review could have produced:
Two months of green builds that never compiled
The Maven wrapper had been committed without its executable bit since the very first commit, so five workflows would have died on their first command. Nothing showed it, because the jobs that ran did not need it and the jobs that needed it were being skipped by the change map. The first job that genuinely required a compiler was the first real deployment.
A security report that was a lie
The image scan reported fourteen images with critical findings. There were none: the runner had run out of disk and the scanner had not opened a single image. Its crash and its findings shared one exit code, so the failure that needed a bigger disk was reported as fourteen vulnerabilities. They are separate codes now, and the closing advice is treated as part of the report.
A verifier that reported its own blindness as a verdict
The build-stamp check declared thirteen modules unverifiable and advised rebuilding all of them. The stamps were there; the check had lost its own credentials and was reading an authentication error as an absent stamp. A control that cannot reach its target must say so — "the thing I audit is wrong" and "I could not run" are different sentences.
A rule that was correct in the repository and absent from the internet
The crawler policy declares eleven closed paths, one of which is a deliberate privacy decision about indexing member profiles by name. The file served to the internet contains none of them and says the opposite. The edge network generates its own version for a feature nobody enabled, so the source is right, the review was right, and the rule is simply not in force. Choosing a reverse proxy means choosing something entitled to answer instead of the origin — and what it has taken over can only be learned by asking the live system, never by reading the code.
What comes next: two environments, chosen by a branch
One environment exists today and it is the one that is live. The shape below is written down before it is built, because three of its constraints are counter-intuitive enough to be learned the expensive way otherwise.
The branch is the only thing a person picks
The development branch deploys the permanent test environment; the main branch deploys the real one. Everything else — hostnames, identity instance, image tags, secrets — derives from that single choice. The deployment definitions are deliberately not forked into two copies: two copies diverge, and the one that gets edited is whichever a person happened to open, so the branch selects values rather than files.
Half of it cannot be promoted, and half of it can
Standard practice is to build an artifact once and move it through environments. The browser bundle compiles its API host in at build time, so the image built for the test host carries that host inside it and production has to rebuild rather than re-tag. The backend images have no build-time environment and promote cleanly. Two halves of one deployment with genuinely different rules is the kind of thing worth writing down before it produces a production front end talking to a test API.
Two environments are two machines
That is a measurement rather than a preference. The fleet already sits at the edge of its box, and the single database cluster is bound by its connection ceiling — which is why every pool size in the composition is explicitly capped. Two projects on one machine is not an environment split; it is one environment with two names and a shared way to fail.
The test environment gets a front door, and it is not application code
A gate written inside the application only ever runs in one environment: it cannot be exercised by the environment it protects and it ships to production as dead code behind a flag. It belongs at the edge, where the request is refused before the origin sees it. And its honest limit is worth stating — it cannot cover the API hostname, because the browser calls that directly with a bearer token rather than a cookie, and a shared secret compiled into a browser bundle is not a secret. So the thing actually worth closing is not a URL, it is account creation, which is a setting in the identity provider rather than a branch in the product.
Delivery in numbers
14
deployed modules
9
CI/CD workflows
32
config gates per pull request
49
documented traps
None of this makes the system correct. It makes its claims checkable, which is the only property that survives being wrong.
By the numbers
Counted, not estimated
Every figure on this page comes from this table, and every row says how it was counted so it can be checked. Line counts include comments: the reasoning lives next to the code in this codebase, deliberately.
| Metric | Value |
|---|---|
| Maven modules | 25 |
| Services | 13 |
| Deployed modules | 14 |
| Backend source files | 1,516 |
| Backend source lines | 70,869 |
| Backend test files | 331 |
| Backend test cases | 1,524 |
| Integration test classes | 27 |
| Use-case interactors | 146 |
| REST controllers | 55 |
| HTTP endpoints | 191 |
| Kafka listener methods | 73 |
| Registered event topics | 26 |
| Database migrations | 106 |
| Security audit findings | 54 |
| Configuration gates per pull request | 32 |
| Alert rules | 18 |
| CI/CD workflows | 9 |
| Documented traps | 49 |
| Frontend source files | 616 |
| Frontend source lines | 55,022 |
| Frontend test cases | 436 |
| Frontend domain modules | 12 |
| Translation keys per locale | 1,298 |
| Commits across repositories | 270 |
Counted from the repositories on 2026-08-09. Development span: 2026-02-28 → 2026-08-09.
Status
What is done, what is deferred, what is open
The system is deployed; the product is not launched. Those are different sentences, and writing down which one is true — along with what is still unfinished and why — is more useful than a page that implies everything shipped.
Shipped
- The migration from modular monolith to a thirteen-service fleet, complete in code and running end to end in containers.
- Physical database-per-service, with every cross-service read served by a locally owned projection.
- Transactional outbox, idempotent consumers, dead-lettering and on-demand replay across the fleet.
- A single authentication boundary at the edge, with roles resolved from the identity service rather than from the token.
- Observability verified live — traces, metrics, alerts with a real destination and an error sink that receives faults and nothing else — and a bounded load test passed with no server errors.
- A four-locale interface across twelve domain modules, with architectural rules enforced in continuous integration.
- A resilience campaign that injected real failures into the running fleet. It found seven genuine defects: an edge that fell open when the identity service was unreachable, a documented rebuild procedure that deleted the read model it was meant to restore, a lost broadcast audit record, and four services continuous integration had never built. All seven are closed, along with the three consistency questions the campaign first left open on purpose.
- A full security audit across eleven phases, run last so that it ran against the final attack surface. Fifty-four findings, no false positives, nine of them critical, and every one closed or recorded with its reason. It left machinery rather than a report: configuration gates on every pull request, a scanned bill of materials, a negative end-to-end tier, and a check that refuses a fleet older than its own code.
- Deployment, end to end and self-verifying: a push builds only what changed, publishes it, applies the composition on the server, waits for every module to report ready, then asks the running fleet which build it is and whether any message is stuck. The frozen monolith was decommissioned once the fleet had been live for a full day — last, and only with explicit sign-off, exactly as the plan said.
- The interface is live on the same box, in four languages, behind the same tunnel — so the product is reachable end to end rather than only the API. The first probe of the running system asked thirteen questions and ten came back clean, including the ones a repository cannot answer: the health endpoint reveals a status and nothing else, ten management endpoints refuse an anonymous caller, an unrouted request returns a problem document that does not echo what was asked for, and sixty parallel requests become forty answers and twenty refusals.
Deferred, with reasons
Payments
Deferred for a reason outside the code: the payment provider only intermediates for a registered company, and there is not one yet, so live collection cannot legally be enabled. The billing service was not deleted; it ships with its paid surface switched off by default while the free-premium rail keeps working. The trigger is a company, not a commit.
Recorded non-goals
Non-blocking retry topics, change-data-capture for the outbox, and a Kubernetes topology with autoscaling. Each is deferred with its reasoning written down, so a future decision starts from the argument rather than from scratch.
Open
- Every threshold in the system — service objectives, rate limits, the daily upload quota — was chosen without any traffic to choose it from. They are deliberate guesses with the reasoning recorded next to them, and they stay guesses until an hour of real load says otherwise.
- The content security policy runs in report-only mode. Moving it to enforcement is not a code change but a measurement: an hour of real traffic and an empty violation report. Enforcing a policy on an assumption once meant nobody could sign in.
- Two things the audit could not close in code, and did not pretend to. The semantic half of prompt-injection defence needs a live run against a deliberately poisoned corpus and a paid model call. Resistance to a coordinated set of fresh accounts is a product decision about account age, not a rate limit: no bucket fills when each account votes once.
- The go-live checklist itself: a live visual acceptance pass across five breakpoints, two themes and four languages, an alert seen in its channel rather than merely accepted by the pipeline, and the sign-in flow exercised against the production identity instance. All of them need a running system and a pair of eyes, which is why none of them is claimed here.
It is deployed, measurable and checkable — and still honest about what it has not finished. Those are not in tension: the second is what makes the first worth saying.