The decision the template forces on you
When you add a new capability to the foundation, you have to answer
one question first: does this belong in the core (every fork that
clones the template gets it, forever) or in a module (verticals
under src/modules/<Name>/ that a fork can include or remove)?
Most engineering teams overthink this. The answer is almost always
one of three patterns. Here they are.
Pattern 1, core
A feature goes in core if every B2B SaaS we can imagine building on
this foundation will need it. The smell test is: would a fictional
medical-records SaaS, a project management tool, and an analytics
dashboard all need this? If yes, core.
Concretely: authentication, multi-tenancy, billing, audit, admin
shell, design system, modularity machinery itself. These are not
"product" features. They are platform features that every B2B SaaS
on the planet needs before it has anything to sell.
The cost of putting something in core is borne by every fork forever.
If we got it wrong, every customer pays. So we move slowly on core.
Pattern 2, module
A feature goes in a module if a meaningful fraction of forks will
not need it. Stripe billing is the canonical example. It's a
vertical that adds a lot, a separate DbContext with a dedicated
Postgres schema, controllers, a webhook pipeline, a reconciliation
worker, settings pages - but a fork billing its customers through an
existing ERP has zero use for it. They should be able to remove it in
a 6-line diff.
The criteria we use:
- Has its own data model. Adds at least 3-4 entities. A vertical
that adds one or two columns to existing tables is not a module -
it's a small extension to core.
- Has its own UI surface. Adds pages, not just toggles on existing
pages.
- Can be removed without breaking core. If the rest of the system
has any compile-time dependency on this module, it's not a module
, it's a core feature pretending to be modular.
The cost of a module is the discipline of keeping the contracts clean
(no cross-module references, no module-to-core type leaks). The
benefit is that forks that don't need it stay smaller, faster to
build, and easier to reason about.
Pattern 3, extension point
The third pattern is what we call an "extension point". It's a hook
in core that a module (or a fork's own code) can opt into without
the core knowing about the consumer.
The IPostibaServerModule interface is itself an extension point, the
core calls RegisterServices, MapEndpoints, and GetDbContextTypes
on every implementation discovered at boot. The core doesn't know
about the billing module specifically. It knows about the shape of
a module.
Same pattern for the workspace SignalR hub. The hub is non-generic on
purpose so modules can dispatch their own client method names without
forcing every module to share one client interface.
Extension points are how we keep core agnostic of modules. When you
find yourself wanting to add if (billingModuleIsActive) { ... } in
core code, the answer is almost always "add an extension point and
let the billing module register through it". That is exactly what
IBillingHooks is: core fires the hook, a no-op implementation ships
in core, and the module replaces it when present.
What we got wrong on the first iteration
The first version of the modular refactor had cross-module leaks we
didn't notice until later. A module's hub payload types were
referenced from IWorkspaceHubClient in core, because the WebApp
SignalR service needed to know how to deserialize them. That meant
the shared assembly ended up with a ProjectReference to a module's
Contracts assembly - core depending on a module.
We caught it before merging because the symptom was obvious: removing
that module became impossible without editing core. The fix was to
split IWorkspaceHubClient (keep OnWorkspaceEvent in core, move the
module-specific methods into a module-side interface that extends it)
and refactor the WebApp realtime service to expose the raw
HubConnection so modules register their own typed handlers.
Lesson: every time you find yourself adding a ProjectReference from
core to a module, stop. There's an extension point you didn't see.
When in doubt, pick module
If you genuinely can't decide between core and module, pick module.
Moving a feature from module to core later is a routine refactor.
Moving it from core to module later is painful, every fork that
inherited it has to migrate.
Optimism about the breadth of an audience is the most common reason
features end up in core that shouldn't be there. Be paranoid about
that.