What is opinionated, and what is replaceable
An opinionated foundation is worth buying when you want to inherit its decisions. It becomes friction the moment you want to replace one. So this page answers the question directly, before you spend anything:
Which decisions am I inheriting? Which are load-bearing? Which are defaults I can change, and what does changing one actually cost?
Where replacing something is invasive, this page says so. A foundation that looked universally flexible would be a foundation with no opinions worth inheriting — and you would find out which it was three months in, not here.
Three categories
Architectural invariant. The design assumes it. Other parts are correct because of it, and replacing it means re-deriving those guarantees yourself. Not impossible — but you would be building a different product, not configuring this one.
Opinionated default. A real decision with real depth behind it, and a real cost to change. Bounded work, spread across more than one file, with consequences to re-verify.
Isolated integration. Behind one module. Replacing it is contained work with a knowable edge.
The short version
| Shipped choice | Category | |
|---|---|---|
| Backend framework | FastAPI + async SQLAlchemy 2.0 | Architectural invariant |
| Database | PostgreSQL 16 | Architectural invariant |
| External identity provider | Clerk | Architectural invariant (the assumption); the provider is a costly default |
| Billing / merchant of record | Paddle | Architectural invariant — and a legal one |
| Metered usage | Append-only credit ledger | Architectural invariant |
| Ownership model | Repository-mediated reads | Architectural invariant |
| Account lifecycle | Soft delete, grace window, hard delete | Architectural invariant |
| Extension boundary | _domain/ directories | Architectural invariant |
| Frontend framework | Next.js App Router + next-intl | Opinionated default |
| Async work | Celery + Redis | Opinionated default |
| Resend | Isolated integration | |
| LLM provider | Anthropic or OpenAI | Isolated integration |
| Coding-agent context | CLAUDE.md + two pointers | Opinionated default, genuinely cheap |
The rest of this page is the detail behind each row.
Architectural invariants
PostgreSQL
Not “we use SQL”. The database module says so outright: the schema uses
JSONB, INET, gen_random_uuid() and partial indexes, and a buyer moving to
another database has to replace them. The models import from
sqlalchemy.dialects.postgresql directly, and two correctness guarantees are
Postgres semantics rather than SQL:
SELECT … FOR UPDATEon the owning user row is what makes the credit balance check atomic — see Credits are a ledger, not a counter and the concurrency test;INSERT … ON CONFLICTis the webhook dedup primitive — see Webhook delivery is idempotent by construction.
There are also partial indexes carrying predicates (WHERE deleted_at IS NULL, and status filters on subscriptions), a GIN index with
jsonb_path_ops, and retry logic keyed on Postgres SQLSTATE codes 40001 and
40P01.
Replacing it touches: the models, the migrations, and both guarantees. On a database with different locking or isolation semantics, “concurrent spends cannot overdraw” and “a replay is a no-op” stop being implied by the code. You would need to re-establish them, and the tests that currently prove them are written against Postgres behaviour.
An external identity provider — and Clerk in particular
This row has two layers, and collapsing them would be misleading.
The assumption is architectural. Identity lives outside the database.
The provider’s opaque user ID is the identity key, a webhook creates the
local row, and users.clerk_user_id is documented as the source of truth.
Other tables — workspaces and audit_logs — store that ID as a
String(64), deliberately not a foreign key, because a User row may
not exist yet when the row is written. Referential integrity there is
enforced by the service layer, not the database.
Which provider fills that role is a default with a high replacement
cost. The backend imports no Clerk SDK — token verification is PyJWT
with PyJWKClient against a JWKS URL, with the issuer checked explicitly.
That core is portable. What is not:
- The identity ID is in your data and in already-sent email.
Unsubscribe links carry
HMAC-SHA256(secret, clerk_user_id)and the ID itself. Change the ID format and every link already in a mailbox stops verifying. Change it in the database and those non-FK string columns have to move with it, with nothing to fail loudly if a row is missed. - Account restore depends on a specific claim. A soft-deleted account
auto-restores only on evidence of a sign-in after deletion, read from
the
auth_timeclaim — deliberately notiat, which refreshes on token rotation. It fails closed: a provider that does not issueauth_timesilently stops restoring accounts rather than erroring. - The issuer is derived by string surgery on the JWKS URL. A provider whose issuer is not that URL minus its well-known suffix fails issuer validation.
- Erasure calls the provider. The hard-delete worker deletes the identity as well as the row, because erasure is not complete while the provider still holds the email — see the GDPR coverage map. A replacement has to offer the same, or Article 17 coverage lapses.
The frontend is committed too: the sign-in and sign-up routes, both layouts,
middleware.ts, and the hook that mints the bearer token for every API call.
Replacing it touches: configuration for the JWKS URL and issuer; a rewrite of the webhook verifier and the user-created handler; a data migration for the identity column wherever it is stored; the erasure worker; the unsubscribe token scheme, with live links already out; and the frontend auth surface. This is bounded and well-marked, and it is not a configuration change.
Paddle, and merchant of record
An invariant for a reason that is not primarily technical.
Paddle is the merchant of record: legally the seller. EU VAT registration, rate determination, invoicing and filing are theirs. Replacing Paddle with a payment processor makes you the merchant of record, and that obligation moves to you. The reasoning, including where Stripe genuinely wins, is published in full.
The code coupling sits on top of that. The Subscription model mirrors
paddle_subscription_id, paddle_customer_id and paddle_price_id, and
its status vocabulary is Paddle’s wire vocabulary — including the American
spelling canceled, with a warning in the model that British cancelled
would silently never match. Those status strings are then baked into SQL
index predicates: the partial unique index that enforces “at most one
active subscription per user” is WHERE status IN ('trialing', 'active').
A different status vocabulary does not error; the index simply stops
matching, and stops enforcing.
Around the model: distinct webhook handlers rather than one upsert (why), an HMAC-signed checkout passthrough (why), a fallback chain for resolving which user a webhook belongs to, a price-ID resolution module, tier configuration, and scheduled workers.
And the shape matters as much as the size: subscription state comes from Paddle. The local table is a mirror, not a source of truth. A replacement has to supply the same event stream, or the lifecycle logic has nothing to mirror.
Replacing it touches: the model and its index predicates, the webhook layer, checkout signing, tier and price configuration, the workers, the billing pages — and your tax position.
The credit ledger
Every credit movement is one INSERT into an append-only table; the balance
is a SUM. Two mechanisms hold it together: UNIQUE (job_id, kind) makes
one logical operation idempotent, and the row lock makes check-then-insert
atomic. The full reasoning and its failure modes are
published, along with the test.
Worth separating: whether you meter at all is your choice. A product
billing a flat subscription simply never spends from the ledger. What is not
a choice is replacing the ledger with a credits_remaining integer and
keeping the guarantees — that column is the exact design this one exists to
avoid.
Repository-mediated ownership
AsyncBaseRepository exposes get, list, add and delete — and
deliberately no ownership-aware read. Each user-owned resource declares
get_for_user / list_for_user on its own repository, so that resource’s
ownership filter lives in exactly one place, in SQL. The repositories that
ship with the factory work this way, and the scaffold generates one per new
resource.
A router reaching a row through repo.get(...) or session.get(Model, id)
has gone around it, which is why that pattern is treated as an IDOR red
flag. See Ownership and access and the
scaffolded example, whose generated test suite
carries the IDOR regression test.
The base class is thin on purpose, and worth knowing before you rely on it:
no commit (transaction control belongs to the caller), no update, no
soft-delete filtering — list() returns deleted rows and each caller
filters deleted_at itself.
Replacing it touches: every read path, the scaffold templates, and the meaning of the generated tests. The IDOR test is written against the repository contract; re-decide where the filter lives and it stops proving what it currently proves.
Account lifecycle and erasure
Soft delete, a grace window, then a worker that hard-deletes and removes the identity at the provider. See Account lifecycle and erasure.
Replacing it touches: Article 17 coverage, and the auth row above — the two are the same code path.
The _domain/ boundary
Buyer-owned files live at reserved _domain/ paths. Upstream ships no
buyer implementation file at those paths, so a file you create there has no
upstream counterpart to be overwritten by — though upstream does own the
placeholder and documentation files inside those directories, the .gitkeep
and the README.md, and may revise them.
That substantially reduces the surface an update can collide with. It does not make arbitrary updates conflict-free: the protection comes from where a file lives rather than from anything the merge does, so work outside those paths reconciles like any other change.
This row is less “replaceable” than the rest — it is a convention rather than a component, and what you would be trading away is that reduced collision surface. The extension boundary page states the guarantee precisely, including where it is narrower than the convenient phrasing, and Taking an update covers what a release merge actually does.
FastAPI + async SQLAlchemy 2.0
The backend is async throughout, with Annotated dependency injection as
the auth contract: CurrentUserDep, AdminDep, DBDep. Every router, every
repository and every test assumes it.
Replacing it touches: all of it. This is a rewrite, and it is the row where “replaceable” stops being a useful word.
Opinionated defaults
Celery + Redis
The task modules and the beat schedule.
Two things make this heavier than it looks:
- Redis is not only the broker. It also backs the LLM response cache, the per-tier rate limits, and a boot-time health check. Replacing Celery does not remove Redis. Two of those uses degrade silently: the cache falls back to no-cache on a Redis outage, and the rate limiter falls back to in-memory storage when no Redis URL is set — which makes the limit per-process rather than shared, multiplying the effective allowance by your worker count with nothing logged.
- The scheduled tasks are correctness machinery, not conveniences. The reconciler refunds spends whose work never completed, every sixty seconds; subscription expiry downgrades lapsed plans hourly; account deletion is the erasure path. Whatever replaces Celery has to run them on a schedule with at-least-once semantics — and keep the JSON-only serializer, because pickle is a remote code execution vector. See Background work.
Replacing it touches: every task module, the beat schedule, the serializer decision, and the retry semantics the reconciler assumes.
Next.js App Router
App Router, server components by default, next-intl locale-prefixed
routing, route groups, and middleware.
The useful distinction is which frontend you mean.
- Replacing the shipped frontend entirely — a native mobile app, a different framework — touches the backend not at all. The boundary is a bearer JWT over HTTP, documented in the boundary contracts table. This is the split working as intended.
- Replacing Next.js while keeping the shipped UI is a rewrite of the shipped frontend. The auth gate, i18n routing and payment-script loading all sit on Next middleware and App Router primitives. Server components by default explains what that buys and what you do not get.
middleware.ts is the most Next-coupled file in the tree, and its own
comments document two ways it fails quietly: a public path missing from the
allowlist serves crawlers a login screen, and a machine endpoint pulled
through locale negotiation 404s instead of erroring. Both have regression
tests, which is the appropriate response to a failure mode that does not
announce itself.
Coding-agent context
Three files: CLAUDE.md, and two short pointers that instruct an agent to
read it. Rewrite them or ignore them — nothing at runtime reads them.
One qualification, because this page is worth nothing if it rounds things off in its own favour: a test module does. It asserts that all three ship, that the two pointers stay pointers rather than becoming a second and third set of instructions, and that every path they cite exists. Delete the files and the suite fails; you would delete or adapt that module with them. That is the whole cost.
This row is the honest counterweight to the rest of the page. It is the one genuinely cheap thing here, and saying so is what should make the “invasive” verdicts above worth believing. All three files are published in full: what an agent is handed, AGENTS.md, the Cursor rule.
Isolated integrations
Resend
The send path is one module. There is no Resend SDK — direct HTTP to one
endpoint, and that URL appears in exactly one file. Everything else in the
product calls send_transactional or send_marketing.
What sits above the wrapper is provider-agnostic and stays: the HMAC unsubscribe token (why that endpoint is public), the templates, and the broadcast dispatcher.
One thing to know before you change it: the marketing-consent gate lives inside the wrapper. Any send path added that bypasses the module bypasses the consent check with it.
Replacing it touches: one module’s HTTP calls and payload shape. This is the row where a provider swap is genuinely contained.
The LLM provider
LLMClient is a real abstraction over Anthropic and OpenAI: one module owns
both SDKs, an enum selects between them, the default comes from
configuration, and call sites pass messages rather than provider-specific
shapes. Errors are normalised, so callers catch one exception type instead
of two. Clients are constructor-injectable, which is what makes it testable
without network access.
Switching between the two implemented providers is a configuration change. Adding a third means writing it inside that module — the abstraction exists, but it is not a plugin system, and nothing discovers a provider nobody has implemented. The module also documents what it does not do: there is no automatic failover, so a 5xx from your primary provider is an error, not a silent switch.
What this adds up to
Eight of these are load-bearing. That is not a defect; it is what an opinionated foundation is. The ledger’s guarantees, the ownership model and the webhook semantics are worth having precisely because they are assumed everywhere rather than offered as options.
So the question worth asking before you buy is not “how much of this can I change?” — it is “are these the decisions I would have made?” If the answer is yes for the invariants, the defaults are negotiable and the integrations are contained. If the answer is no for even one invariant, this foundation will fight you, and it is much cheaper to learn that from this page than from a quarter of work.