Fork Guide
How to fork Wallow, configure modules, add new functionality, and stay in sync with upstream changes.
Overview
Wallow is designed as a base platform that teams fork and extend. Each fork becomes an independent product while retaining the ability to pull improvements from the upstream Wallow repository.
wallow (upstream) your-product (fork)
| |
|-- main <----- PR -------- feature-branches
| |
| generic improvements | product-specific code
| flow back via PR | lives only in fork
| |
v2.0 ---- git merge ------> fork pulls upstream
v2.1 ---- git merge ------> fork pulls upstream
Prerequisites
- .NET 10 SDK
- Node 24 (see
.nvmrc) and pnpm 11.24.0 — the React apps and every shared package live in a pnpm workspace, and Approach A's very first customization step edits a file inside it - Docker and Docker Compose
- PostgreSQL (via Docker or standalone)
- Git
Trademark and Attribution
See the NOTICE file in the repository root. "Wallow" is a trademark of BC Solutions Ltd. If you distribute a derivative product under a different name, you must:
- Remove or replace the Wallow name and branding (see Approach B below)
- Retain the
NOTICEfile and MIT license attribution - Not use "Wallow" in your product name without written permission
Approach A (Recommended): Keep Namespaces, Customize via Config
The simplest fork strategy is to keep all Wallow.* namespaces unchanged and customize the user-facing product identity through configuration only:
- Fork and clone the repository
- Edit
packages/styles/branding.jsonto set your product name, icon, tagline, landing-page toggle, and theme colors - Edit
api/src/Wallow.Api/appsettings.jsonto configure connection strings, the SMTP sender name, and the OpenTelemetry service name - Edit
api/seed.jsonto set your bootstrap organization, roles, and admin account - Set up the merge driver so upstream merges don't overwrite your config (see "Merge Driver Setup" below)
Production seeds differently.
api/seed.jsonis the development seed; production uses the committeddocker/seed.production.json, which is secret-less (client secrets are injected asClientSecrets__<clientId>environment variables) and carries noadminblock — a production deployment bootstraps its administrator through the first-run/setuppage instead of configuration. Edit that file in place for your fork's clients; see the Deployment Guide.
This approach has zero risk of silent failures and gives you the easiest upstream sync path. All fork identity in the React apps -- page titles, auth screens, theme colors -- is resolved from packages/styles/branding.json by packages/styles, so no source changes are needed. See the Configuration Guide for the full key reference.
Merge Driver Setup
The repository ships a .gitattributes that marks fork-owned files with merge=ours. Activate the merge driver:
git config merge.ours.driver true
The entries it covers are:
| Pattern | What it protects |
|---|---|
appsettings*.json |
Every app settings file, in every project |
branding.json |
Fork branding and theme (packages/styles/branding.json) |
docker/.env |
Your local Compose credentials |
docker/.env.example |
Fork-specific additions to the example env |
seed.json |
Bootstrap tenant, roles, and admin (api/seed.json) |
docker/seed.production.json |
The committed, secret-less production seed |
These files keep your fork's version during upstream merges. Anything outside this list -- including CLAUDE.md and .claude/** -- merges normally, so expect to resolve conflicts there yourself.
The protection is unconditional -- diff these files after every sync
merge=ours does not weigh the two sides. It resolves the file to your fork's version and reports
success: no conflict markers, no merge message, nothing in git status. That is exactly what you
want for the values in these files -- your connection strings, your bootstrap admin, your palette
-- but it applies just as unconditionally to upstream changes to a file's shape: a key upstream
renames, a key upstream deletes, a block upstream adds. Your fork keeps whatever it had, and the
merge looks clean.
So make "diff the protected files against upstream" a step in every sync. After merging:
git ls-files | git check-attr --stdin merge | grep ': merge: ours$' | sed 's/: merge: ours$//' \
| xargs git diff upstream/main --
That asks git itself which tracked paths carry merge=ours -- so it stays correct if the pattern
list grows, and it expands appsettings*.json to every environment overlay -- then diffs each one
against upstream's copy. docker/.env never appears because it is untracked by design; diff it
against docker/.env.example instead. Everything the command prints is either a deliberate fork
customization or drift you have not reconciled yet, and only you can tell those apart, so read the
output rather than automate it. Reconciling is manual by definition: hand-apply the upstream key
changes you want, and leave your own values in place.
Worked example -- the Modules.* flag cleanup. Upstream deleted two FeatureManagement keys
from appsettings.json and appsettings.Production.json -- Modules.Identity, which never did
anything because Identity is a core module and is registered regardless, and
Modules.Configuration, which named a module that has never existed -- along with a dead top-level
Wallow:Modules block that nothing read. A fork that merges that change keeps all three. They are
inert, so the application behaves identically; they are also a lie about which modules exist, and
Wallow.Architecture.Tests now asserts otherwise. Its ModuleFeatureFlagTests loads the real
checked-in appsettings*.json files and fails if the declared Modules.* set is anything but
exactly the non-core modules in WallowModuleRegistry.All, or if a second Wallow:Modules block is
present. An unreconciled fork therefore gets a red test suite with no compile error and nothing
pointing back at the merge that caused it. Delete the stale keys from your copy and it goes green;
the flags that legitimately remain are described under Configuring Modules.
The same trap bites branding.json in the other direction -- a fork whose palette predates a token
upstream added never receives it -- which is why new theme tokens ship with a fallback. See the
Configuration Guide.
Approach B (Advanced): Full Namespace Rename
If you need to remove all Wallow references from source code (e.g., for white-label distribution), follow this approach. Be aware that namespace renaming can cause silent failures — see the checklist below.
1. Fork and clone
git clone git@github.com:your-org/YourProduct.git
cd YourProduct
The .NET side of the repository lives entirely under api/: the solution is api/Wallow.slnx, projects are under api/src/, and test projects under api/tests/. The commands below assume you run them from the repository root.
2. Rename the solution file
mv api/Wallow.slnx api/YourProduct.slnx
3. Rename namespaces across the codebase
Every Wallow.* namespace, project name, and assembly reference must become YourProduct.*.
Rename directories and project files:
# Rename project directories (deepest first, so parents stay valid mid-loop)
find api/src api/tests -depth -type d -name 'Wallow.*' | while read dir; do
mv "$dir" "$(echo "$dir" | sed 's/Wallow\./YourProduct./')"
done
# Rename .csproj files
find api/src api/tests -name 'Wallow.*.csproj' | while read f; do
mv "$f" "$(echo "$f" | sed 's/Wallow\./YourProduct./')"
done
Replace namespace strings in all source files:
find api \( -name '*.slnx' -o -name '*.csproj' -o -name '*.props' -o -name '*.cs' \
-o -name '*.json' \) \
-not -path '*/bin/*' -not -path '*/obj/*' \
-exec sed -i '' 's/Wallow\./YourProduct./g' {} +
# Catch standalone "Wallow" references (log messages, display names, etc.)
# Review these manually — some may be intentional:
grep -rl '"Wallow"' api --include='*.cs' --include='*.json' \
--exclude-dir=bin --exclude-dir=obj
Alternatively, use your IDE's global Find and Replace. JetBrains Rider handles this well with Edit > Find and Replace in Files.
4. Update the solution file references
Open api/YourProduct.slnx and verify all project paths point to the renamed .csproj files. The sed pass above should handle this, but confirm with:
grep 'Wallow\.' api/YourProduct.slnx
Should return nothing.
5. Update configuration and build files
| File | What to change |
|---|---|
docker/.env |
COMPOSE_PROJECT_NAME |
docker/docker-compose.yml |
Network name, container prefixes |
docker/docker-compose.production.yml |
Image names (ghcr.io/<org>/<image>), container names |
api/src/Wallow.Api/appsettings.json |
OpenTelemetry:ServiceName, Smtp:DefaultFromName |
packages/styles/branding.json |
appName, tagline, appIcon |
api/Directory.Build.props, api/Directory.Packages.props |
Any hardcoded product name or assembly prefix |
.github/workflows/*.yml |
Database names, connection strings, deploy paths, image names |
There is no root Dockerfile. The .NET images are produced by the .NET SDK container tooling -- dotnet publish /t:PublishContainer in .github/workflows/ci.yml and deploy.yml -- driven by the <ContainerRepository> properties in api/src/Wallow.Api/Wallow.Api.csproj, Wallow.MigrationService.csproj, and Wallow.SeederService.csproj. Rename those properties rather than editing a Dockerfile. The only Dockerfiles in the repository build the React apps (apps/wallow-web/Dockerfile, apps/wallow-auth/Dockerfile) and supporting infrastructure images (docker/docs/, docker/images/*/).
6. Rename the frontend workspace (optional)
Renaming the .NET namespaces does not touch the pnpm workspace. If you also want to re-scope the TypeScript packages, change the name fields in each packages/*/package.json and apps/*/package.json from @bc-solutions-coder/* to your own scope, update the matching workspace:* dependency keys, update .npmrc for your registry, and re-run pnpm install to regenerate the lockfile.
7. Handler discovery — nothing to rename
Wolverine's handler discovery has no assembly-name prefix to update. Program.cs builds its
discovery list from enabledModules.SelectMany(module => module.HandlerAssemblies), and each module
declares its own assemblies with typeof(...) anchors inside its IWallowModule implementation
(api/src/Modules/{Module}/*.Infrastructure/Modules/{Module}Module.cs). Renaming namespaces moves
those anchors and the discovery list follows automatically — a rename that broke one would be a
compile error, not a silently empty scan.
8. Build and verify
dotnet restore api/YourProduct.slnx
dotnet build api/YourProduct.slnx
./scripts/run-tests.sh
Fix any remaining Wallow references the compiler surfaces.
Silent Failure Checklist
These components reference "Wallow" as a string literal and will fail silently if you rename namespaces but miss them:
| Component | File | What breaks |
|---|---|---|
| ModuleEnricher | api/src/Wallow.Api/Logging/ModuleEnricher.cs |
Log enrichment stops tagging module names |
| OpenTelemetry ServiceName | api/src/Wallow.Api/appsettings*.json → OpenTelemetry:ServiceName |
Traces/metrics report wrong service name |
| Diagnostics ActivitySource | api/src/Shared/Wallow.Shared.Kernel/Diagnostics.cs |
Custom traces stop appearing (new ActivitySource("Wallow")) |
| SMTP DefaultFromName | api/src/Wallow.Api/appsettings.json → Smtp:DefaultFromName |
Emails show "Wallow" as sender |
| branding.json | packages/styles/branding.json → appName |
React app titles and auth screens show "Wallow" |
| Email templates | SimpleEmailTemplateService in the Notifications module (api/src/Modules/Notifications/Wallow.Notifications.Infrastructure/Services/) |
Email bodies may contain hardcoded product name |
| Container repositories | <ContainerRepository> in Wallow.Api.csproj, Wallow.MigrationService.csproj, Wallow.SeederService.csproj |
Published images keep the upstream image names |
After renaming, search for remaining literal references:
grep -r '"Wallow"' --include='*.cs' --include='*.json' \
--exclude-dir=bin --exclude-dir=obj --exclude-dir=node_modules .
Frontend Authentication Policy: BFF-only
Every frontend in this repository authenticates through a Backend-For-Frontend: the browser
never holds an access token, a confidential server-side client runs the authorization-code flow,
and the token set lives in a sealed httpOnly session cookie. Your fork inherits that default,
so it is worth stating plainly what kind of rule it is.
It is a policy choice, not a standards mandate. The IETF's OAuth 2.0 for Browser-Based
Applications is still an Internet-Draft (draft-ietf-oauth-browser-based-apps) in the RFC
Editor queue, with no RFC number assigned — treat any doc or commit message claiming otherwise
as wrong. That draft ranks three architectures in decreasing order of security rather than
mandating one:
- Backend-For-Frontend — "strongly recommended for business applications, sensitive applications, and applications that handle personal data"
- Token-mediating backend — the server holds the tokens but the browser drives the calls
- Browser-based public client — tokens in the browser, PKCE only
Wallow adopts tier 1 for everything and does not ship the other two. That is a layer of policy on top of the ranking, and it is the right default for a fork-first platform: every downstream deployment inherits whatever this repository chooses, so the choice should be the one that is safe when nobody revisits it.
The argument that decides it is §5.1.3 of the draft. Even with browser tokens protected perfectly, an attacker with XSS on your origin can run a silent authorization-code flow in a hidden iframe and mint entirely fresh tokens of their own. The draft is blunt that there are no practical countermeasures for a frontend in that position — short token lifetimes and refresh rotation do not help, because the attacker is not stealing your token, they are getting their own. Only a confidential-client BFF defeats it: the attacker obtains an authorization code they cannot exchange without the server-side secret.
If your fork needs a different tier, the escape hatch is a token-mediating backend (the Curity "token handler" pattern) — the server still owns the tokens and the confidential client, but hands the browser short-lived, narrowly-scoped credentials. Taking it means owning that decision explicitly:
- Keep the confidential client and the server-side token store. Do not move a refresh token into the browser under any circumstances.
- Audience-restrict and scope-narrow whatever the browser does receive, so an XSS compromise yields the smallest possible authority.
- Document the deviation in your fork's own docs. Upstream's guides, defaults, and E2E fixtures all assume the BFF, and a silent divergence is how a deployment ends up with neither model implemented completely.
The mechanics of the supported path — mounting the tunnel, the CSRF gate, session stores, and per-request SDK instances — are in the BFF Pattern and TypeScript SDK guides, with a start-to-finish walkthrough in the Integration Cookbook.
Origins, Issuer, and the Ingress Contract
Rebranding a fork is configuration-only, but re-hosting one is not: the moment you change a hostname, a port, or a path prefix, you are editing one member of a coupled set. The API, the auth app, and every BFF have to agree on which origin is the OIDC issuer, and the OAuth client records have to agree on where the browser is allowed to be sent back. Change one and leave the rest and the deployment still builds, still boots, and still passes health checks — it just fails at login.
Change one origin, change all of them
| If you move… | Also update |
|---|---|
| The API's public URL | API_PUBLIC_URL (production compose feeds it to ServiceUrls__ApiUrl, the API base URL registered applications are shown), and API_PATH_BASE if the prefix changed. |
| The auth app's origin | AUTH_PUBLIC_URL (production compose feeds it to the API's OpenIddict__Issuer and the web app's OIDC_ISSUER), the API's AuthUrl (appsettings*.json — the issuer fallback when OpenIddict:Issuer is unset), and AUTH_BASE_PATH if the prefix changed. AUTH_BASE_PATH is a build argument, not runtime. |
| A frontend's origin | That app's OIDC_REDIRECT_URI and OIDC_POST_LOGOUT_REDIRECT_URI, and the URIs registered on its OAuth client — redirectUris / postLogoutRedirectUris in api/seed.json for seeded clients, or the application's settings in the dashboard. |
| The parent domain | COOKIE_DOMAIN (Authentication__CookieDomain). It scopes the API's identity cookies, and a leading-dot value widens them to every subdomain of that parent, present and future. Set it as narrowly as your topology allows. Values per topology: Reverse Proxy → Required Configuration. |
Prefer deriving these from one variable over setting each by hand — that is why the production
compose reads API_PUBLIC_URL in two places instead of taking two independent inputs. Whether the
issuer is the API's origin or the auth app's is an environment-by-environment decision, and this
repository answers it differently in dev, E2E, and production; the table and the reasoning are in
The Issuer and Origin Contract.
Giving another app a path prefix
Only wallow-auth currently has a base-path knob (AUTH_BASE_PATH). If you put wallow-web
behind a path prefix too, the prefix has to reach that app's branding assets, not
just its router. It imports appIconUrl and forkResolvedBranding from
@bc-solutions-coder/styles directly, and the package resolves them at the site root: it ships
a prebuilt bundle, so its own import.meta.env.BASE_URL is whatever it was when the package was
built, never yours. Behind a path-based ingress the site root is a different app, so the icons
silently resolve into it.
Mirror apps/wallow-auth/src/shared/lib/branding.ts: one per-app module that hands the prefix to
the package's base-path-aware functions (toAppIconUrl, resolveForkBranding, and
mergeClientBranding for fetched client branding) exactly once, with every screen importing
branding from that module rather than from the package. The string arithmetic itself —
normalizeBasePath, toViteBase, stripBasePath, withBasePath — is already shared in
@bc-solutions-coder/env/base-path; only the binding is per-app. Note that the prefix is a build
argument, not a runtime one, for the reason given in apps/wallow-auth/src/shared/lib/base-path.ts.
Two contracts a fork must not break silently
Both of these hold regardless of how you rebrand or re-host, and both fail in ways that do not point back at the change that caused them.
- Your ingress must send
X-Forwarded-Proto: https, and the frontends must trust it. The reference stack ships a Caddy ingress (Deployment → Routing Topologies), and replacing it is supported — but the replacement inherits this requirement. Without the header the API buildshttp://redirect URIs and discovery documents,Securecookie logic misreads the connection, and server-rendered queries compute a different base URL than the browser does, so hydration re-fetches instead of reusing. The Node apps believe the header only from a peer insideWALLOW_TRUSTED_PROXIES(the production compose defaults it toprivate), so an ingress on an address outside that list re-creates the same symptoms with the header present. Details on the proxy side are in Reverse Proxy → Forwarded Headers; the frontend consequences are in What the BFF requires from your ingress. - The OIDC callback must stay a top-level GET redirect. The login-transaction cookie holding
the PKCE verifier,
state, andnonceis writtenSameSite=Lax, which survives a top-level navigation and nothing else. Switching toresponse_mode=form_post, or running the flow in an iframe, means the cookie is never sent and every callback 400s. See The Callback Must Stay a Top-Level GET Redirect.
Data Protection (GDPR)
If you operate a fork that processes personal data of EU residents, you are the data controller. Key responsibilities:
- Update the privacy policy (
/privacypage) to reflect your organization - Configure data retention policies appropriate for your jurisdiction
- Ensure the
NOTICEfile attribution does not imply upstream Wallow is the data processor - Review tenant data isolation — each module uses separate PostgreSQL schemas with query filters on
TenantId
Configuring Modules
Wallow ships with multiple modules. Most are enabled by default and can be toggled via feature flags -- no source code changes required. Identity is always registered (not behind a feature flag).
Enabling and disabling modules
Modules are controlled by the FeatureManagement section in appsettings.json. Each key maps to Modules.{ModuleName} with a boolean value:
{
"FeatureManagement": {
"Modules.Branding": true,
"Modules.Storage": true,
"Modules.Notifications": true,
"Modules.Announcements": true,
"Modules.Inquiries": true,
"Modules.ApiKeys": false
}
}
Only the six optional modules appear. Identity is a core module: it is always registered, so it has no flag and adding one would toggle nothing.
To disable a module, set its value to false:
{
"FeatureManagement": {
"Modules.Announcements": false
}
}
This is wired in WallowModules.cs: IsModuleFlagEnabled reads FeatureManagement:Modules.{Name} straight off IConfiguration once at startup, and filters WallowModuleRegistry.All down to the enabled set. Identity is always registered as a required platform dependency. When a module is disabled, its DI services, HTTP controllers, and Wolverine handlers are all excluded from the application — the endpoints return 404 and are absent from the OpenAPI document.
The value must be a scalar boolean. An absent key reads as disabled, but a key present as anything other than true/false — a Microsoft.FeatureManagement filter object with EnabledFor or RequirementType, say — makes the host refuse to start. A module flag is read once at startup, so there is no request in flight for a filter to evaluate against, and a module that silently disabled itself would take its endpoints with it. Gate request-scoped behaviour inside the module instead of switching the module off.
IFeatureManager itself is still registered (services.AddFeatureManagement()) and is still the supported extension point for your fork's own feature flags — inject it in your handlers and controllers as usual. It is only Wallow's module enable/disable gating that no longer goes through it.
Its database schema is still migrated, though: Wallow.MigrationService deliberately ignores feature flags and migrates every module the platform ships. A fork that disables a module can therefore re-enable it later without a manual migration step.
Module-specific configuration
Each module reads its own configuration section from appsettings.json. See the Configuration Guide for the sections a fork is most likely to change (Smtp, Storage, OpenTelemetry, etc.); appsettings.json itself remains the complete list.
Environment-specific overrides
Use appsettings.{Environment}.json or environment variables to configure modules per deployment target:
# Disable announcements in development
FeatureManagement__Modules.Announcements=false
# Configure SMTP for production
Smtp__Host=smtp.example.com
Smtp__Port=587
Smtp__UseSsl=true
Adding a New Module
The canonical step-by-step walkthrough is the
Module Creation Guide; follow it with your fork's
namespace substituted for Wallow if you took Approach B (so
YourProduct.YourModule.Domain/Application/Infrastructure/Api under
api/src/Modules/YourModule/). In outline: create the four class libraries with the Clean
Architecture references, put integration event records in
Shared.Contracts/YourModule/Events/, implement IWallowModule once in the module's
Infrastructure layer, add one entry to WallowModuleRegistry.All, add a row for one of the
module's controller types to _moduleApiAssemblies in WallowModules.cs, add the four
projects to the solution file, and declare the module's
FeatureManagement:Modules.YourModule flag. Wolverine discovers handlers in exactly the
assemblies the module's HandlerAssemblies names — no assembly scan, no name prefix — so a
renamed namespace changes nothing about discovery.
Two fork-specific notes the walkthrough does not dwell on:
- Api must not reference its own module's Infrastructure.
CleanArchitectureTests.ApiLayer_ShouldNotDependOn_InfrastructureLayerasserts exactly that, per module. The module's DI registration therefore belongs in Infrastructure — anAddYourModuleInfrastructure(...)extension that the composition root calls — not in the module's Api project. - The test reads IL rather than the project file, so an unused
ProjectReferenceslips past it (Wallow.Identity.Api.csprojcarries one and still passes). Do not treat that as permission: add the reference and the first type you use across the boundary fails the suite.
The flag your module declares is how a fork ships it disabled by default — see Configuring Modules above, and Adding Plugins and Extensions below for when a plugin fits better than a module.
Configuring Multi-Tenancy for New Modules
Every module that stores tenant-specific data must integrate with the multi-tenancy infrastructure from Shared.Kernel.
1. Mark domain entities as tenant-scoped
Implement ITenantScoped on any entity that belongs to a tenant:
using YourProduct.Shared.Kernel.MultiTenancy;
public class Order : AggregateRoot, ITenantScoped
{
public string OrderNumber { get; private set; }
public TenantId TenantId { get; set; }
}
2. Derive from TenantAwareDbContext<T>
Do not hand-write HasQueryFilter per entity. TenantAwareDbContext<T> (namespace Wallow.Shared.Infrastructure.Core.Persistence) carries the tenant plumbing, and one call to ApplyTenantQueryFilters(modelBuilder) applies the filter to every entity that implements the tenant-scoped interface — including the ones you add later:
public sealed class OrderDbContext : TenantAwareDbContext<OrderDbContext>
{
public DbSet<Order> Orders => Set<Order>();
public OrderDbContext(DbContextOptions<OrderDbContext> options) : base(options)
{
ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("orders");
modelBuilder.ApplyConfigurationsFromAssembly(typeof(OrderDbContext).Assembly);
ApplyTenantQueryFilters(modelBuilder);
}
}
HasQueryFilter appears in exactly two files across api/src, both inside the shared base class. A per-entity filter in your module is a filter someone will forget on the next entity.
3. Register the context
Register a pooled factory, then the tenant-aware scoped wrapper and the read context. The interceptor that stamps TenantId on new entities goes on the factory's options:
services.AddPooledDbContextFactory<OrderDbContext>((sp, options) =>
{
options.UseNpgsql(connectionString, npgsql =>
npgsql.MigrationsHistoryTable("__EFMigrationsHistory", "orders"));
options.AddInterceptors(sp.GetRequiredService<TenantSaveChangesInterceptor>());
});
services.AddTenantAwareScopedContext<OrderDbContext>();
services.AddReadDbContext<OrderDbContext>(configuration);
AddTenantAwareScopedContext<T>() is what resolves the current tenant per scope and hands it to the pooled instance — pooling and a constructor-injected ITenantContext are incompatible, which is the reason the base class takes the tenant from the scope rather than from its constructor.
4. Create a design-time factory
dotnet ef constructs the context outside the DI container, so give it an
IDesignTimeDbContextFactory<T> in your Infrastructure project's Persistence folder. Because the context takes no ITenantContext in its constructor, the factory needs nothing but a connection string:
public class OrderDbContextFactory : IDesignTimeDbContextFactory<OrderDbContext>
{
public OrderDbContext CreateDbContext(string[] args)
{
DbContextOptionsBuilder<OrderDbContext> optionsBuilder = new();
string password = Environment.GetEnvironmentVariable("WALLOW_DB_PASSWORD") ?? "wallow";
optionsBuilder.UseNpgsql($"Host=localhost;Database=wallow;Username=wallow;Password={password}");
return new OrderDbContext(optionsBuilder.Options);
}
}
Every module ships one of these — AnnouncementsDbContextFactory is the shortest to copy.
5. Raw SQL
The global filter is an EF Core construct, so anything that bypasses EF bypasses the filter. If you drop to raw SQL — FromSql on the read context, or any hand-written query — you must filter by tenant yourself:
WHERE tenant_id = @TenantId
Pass the ambient ITenantContext.TenantId.Value as the parameter. This is the one place in a module where forgetting a WHERE clause is a cross-tenant data leak rather than a bug.
Adding API Endpoints
Controllers live in the Api layer of your module and depend only on Application.
1. Create a controller
In YourProduct.YourModule.Api/Controllers/:
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly IMessageBus _bus;
public OrdersController(IMessageBus bus)
{
_bus = bus;
}
[HttpPost]
[HasPermission(PermissionType.OrdersCreate)]
public async Task<IActionResult> Create([FromBody] CreateOrderRequest request)
{
Result<OrderDto> result = await _bus.InvokeAsync<Result<OrderDto>>(
new CreateOrderCommand(request.CustomerId, request.Items));
return result.ToActionResult();
}
[HttpGet("{id:guid}")]
[HasPermission(PermissionType.OrdersRead)]
public async Task<IActionResult> GetById(Guid id)
{
Result<OrderDto> result = await _bus.InvokeAsync<Result<OrderDto>>(
new GetOrderByIdQuery(id));
return result.ToActionResult();
}
}
2. Add permissions
If your module needs new permissions, add string constants to PermissionType in api/src/Shared/Wallow.Shared.Kernel/Identity/Authorization/PermissionType.cs and update the role-to-permission mapping in the Identity module's RolePermissionMapping.cs.
3. Request/Response contracts
Define request and response types in the Api layer:
public record CreateOrderRequest(Guid CustomerId, List<OrderItemRequest> Items);
public record OrderItemRequest(Guid ProductId, int Quantity);
DTOs live in the Application layer. Requests and responses live in the Api layer.
Adding Domain Events and Consumers
Define the event
Add integration events to Shared.Contracts so any module can consume them:
api/src/Shared/YourProduct.Shared.Contracts/YourModule/Events/OrderPlacedEvent.cs
namespace YourProduct.Shared.Contracts.YourModule.Events;
public sealed record OrderPlacedEvent : IntegrationEvent
{
public required Guid OrderId { get; init; }
public required Guid CustomerId { get; init; }
public required decimal Total { get; init; }
}
Events use primitive types only -- no strongly-typed domain IDs. This keeps serialization simple across module boundaries. Name events in past tense. They are facts, not commands.
Publish the event
From any handler, publish after the operation succeeds:
await bus.PublishAsync(new OrderPlacedEvent
{
OrderId = order.Id,
CustomerId = order.CustomerId,
Total = order.Total
});
Create a consumer in another module
In the consuming module's Application or Infrastructure layer, Wolverine discovers handlers by convention:
// In Notifications.Application/EventHandlers/
public static class OrderPlacedEventHandler
{
public static async Task HandleAsync(
OrderPlacedEvent @event,
INotificationService notifications,
CancellationToken ct)
{
await notifications.CreateAsync(
@event.CustomerId,
$"Order {@event.OrderId} placed for {@event.Total:C}",
ct);
}
}
Wolverine discovers handlers by convention inside the assemblies the module declared in
HandlerAssemblies — which is always both its .Application and its .Infrastructure assembly — so
a handler in either layer needs no manual registration.
Adding Migrations
Each module manages its own migrations through its Infrastructure project.
Create a migration
dotnet ef migrations add InitialCreate \
--project api/src/Modules/YourModule/YourProduct.YourModule.Infrastructure \
--startup-project api/src/YourProduct.Api \
--context YourModuleDbContext
Apply the migration
Wallow.MigrationService applies every module's migrations, and Aspire runs it before the API starts — so pnpm backend is the normal way to get a new migration onto your local database. Register your context with the migration service when you add the module.
To apply one context by hand instead:
dotnet ef database update \
--project api/src/Modules/YourModule/YourProduct.YourModule.Infrastructure \
--startup-project api/src/YourProduct.Api \
--context YourModuleDbContext
Nothing migrates on API startup. Modules have no startup hook at all; the only inline path is WallowModules.RunTestMigrationsAsync, which is gated to the Testing environment so integration tests can migrate their own Testcontainers database. Running Wallow.Api on its own against an un-migrated database fails at first query rather than fixing itself.
Adding Plugins and Extensions
Wallow includes a plugin system for product-specific extensions that load dynamically without modifying core code. Plugins are the recommended way to add fork-specific functionality because they don't create merge conflicts when syncing upstream.
Plugin structure
A plugin is a .NET class library that implements IWallowPlugin and ships with a plugin.json manifest:
plugins/
your-plugin/
plugin.json
YourPlugin.dll
Manifest (plugin.json):
{
"id": "your-plugin",
"name": "Your Plugin",
"version": "1.0.0",
"description": "Product-specific extension",
"author": "Your Team",
"minWallowVersion": "4.0.0",
"entryAssembly": "YourPlugin.dll",
"dependencies": [],
"requiredPermissions": ["storage:read", "messaging:send"],
"exportedServices": []
}
All ten keys are required — PluginManifest is a positional record, so a missing one fails deserialization. minWallowVersion is declarative only: nothing in the loader compares it against the backend's <Version> (currently 4.0.0 in api/Directory.Build.props). Record the version you actually built against; do not expect it to stop a mismatched plugin from loading.
Plugin entry point:
public class YourPlugin : IWallowPlugin
{
public PluginManifest Manifest => // loaded from plugin.json
public void AddServices(IServiceCollection services, IConfiguration configuration)
{
// Register your DI services
}
public Task InitializeAsync(PluginContext context)
{
// Run startup logic
return Task.CompletedTask;
}
public Task ShutdownAsync()
{
// Cleanup
return Task.CompletedTask;
}
}
Plugin configuration
{
"Plugins": {
"PluginsDirectory": "plugins/",
"AutoDiscover": true,
"AutoEnable": false,
"Permissions": {
"your-plugin": ["storage:read", "messaging:send"]
}
}
}
| Setting | Default | Description |
|---|---|---|
PluginsDirectory |
plugins/ |
Directory to scan for plugin assemblies |
AutoDiscover |
true |
Automatically discover plugins on startup |
AutoEnable |
false |
Automatically load all discovered plugins |
Permissions |
{} |
Per-plugin permission grants |
Plugins are loaded in an isolated AssemblyLoadContext, so they cannot interfere with core module assemblies.
When to use plugins vs modules
| Use case | Approach |
|---|---|
| Generic capability useful across products | Module in core Wallow |
| Product-specific feature that only your fork needs | Plugin |
| Feature you want to develop in your fork and later contribute upstream | Start as a plugin, then convert to a module when contributing |
Running Tests for New Modules
1. Create the test project
Each module uses a single test project with subdirectories for each layer:
api/tests/Modules/YourModule/YourProduct.YourModule.Tests/
Domain/
Application/
Infrastructure/
mkdir -p api/tests/Modules/YourModule
cd api/tests/Modules/YourModule
dotnet new xunit -n YourProduct.YourModule.Tests
Add references to the module layers and the shared test infrastructure:
dotnet add reference ../../../api/src/Modules/YourModule/YourProduct.YourModule.Domain
dotnet add reference ../../../api/src/Modules/YourModule/YourProduct.YourModule.Application
dotnet add reference ../../../api/src/Modules/YourModule/YourProduct.YourModule.Infrastructure
dotnet add reference ../../YourProduct.Tests.Common/YourProduct.Tests.Common.csproj
Add the test project to the solution:
dotnet sln api/YourProduct.slnx add api/tests/Modules/YourModule/YourProduct.YourModule.Tests
2. Unit tests
Test handlers in isolation by mocking repositories and services:
[Fact]
public async Task Should_create_order()
{
IOrderRepository repo = Substitute.For<IOrderRepository>();
CreateOrderCommand command = new(customerId, items);
Result<OrderDto> result = await CreateOrderHandler.HandleAsync(command, repo, CancellationToken.None);
result.IsSuccess.Should().BeTrue();
await repo.Received(1).SaveChangesAsync();
}
3. Integration tests
Use the shared WebApplicationFactory with Testcontainers from Tests.Common. Prefer ICollectionFixture over IClassFixture for container sharing:
[Collection("Api")]
public class OrdersControllerTests
{
private readonly HttpClient _client;
public OrdersControllerTests(WallowApiFactory factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task CreateOrder_returns_201()
{
HttpResponseMessage response = await _client.PostAsJsonAsync("/api/orders", request);
response.StatusCode.Should().Be(HttpStatusCode.Created);
}
}
Integration tests require Docker. Testcontainers spins up ephemeral Postgres and Valkey containers.
4. Run tests
# All tests
./scripts/run-tests.sh
# Only your module
./scripts/run-tests.sh api/tests/Modules/YourModule/YourProduct.YourModule.Tests
Syncing Upstream Changes
Initial setup (one-time)
git remote add upstream https://github.com/your-org/Wallow.git
git fetch upstream
Pulling updates (tagged-release sync)
For stability, sync from tagged releases rather than upstream/main:
git fetch upstream --tags
git checkout main
git merge v2.1.0 # merge a specific release tag
Alternatively, track the latest main:
git fetch upstream
git checkout main
git merge upstream/main
Resolving conflicts
Conflicts typically occur in files where you renamed Wallow to YourProduct. The recommended workflow:
- Accept the upstream version of the conflicted file
- Re-apply the
Wallow -> YourProductreplacement on that file - Review the diff to confirm the upstream logic change was preserved
For large upstream merges, consider cherry-picking specific commits:
git cherry-pick <commit-hash>
Reducing merge friction
- Avoid modifying shared projects --
Shared.KernelandShared.Contractsare the highest-conflict areas. Extend them sparingly. - Keep product-specific logic in plugins or your own modules -- not in core projects.
- Merge upstream regularly -- small, frequent merges are easier than large catch-up merges.
- Prefer extending over modifying -- when adding features to existing modules, add new files rather than editing existing ones where possible.
- Track upstream-intended commits -- prefix commits meant for contribution with
[wallow]in the commit message for easy identification.
Recommended sync cadence
| Stage | Cadence |
|---|---|
| Active upstream development | Weekly merge |
| Stable upstream, active fork development | Bi-weekly merge |
| Both stable | Monthly merge or on release tags |
Contributing Changes Back Upstream
When you build something generic in your fork that would benefit the base platform, contribute it back via pull request.
Workflow
- Build the feature in your fork -- develop and validate it in your product context.
- Identify generic vs product-specific parts -- separate business logic that is product-specific from infrastructure that is reusable.
- Re-implement generically in a clean branch off upstream/main:
git fetch upstream
git checkout -b feat/my-feature upstream/main
# Implement the generic version
git push origin feat/my-feature
- Open a PR against the upstream repository following Wallow's commit conventions (
feat:,fix:, etc.). - After the PR is merged, sync upstream into your fork to replace your fork-specific version with the upstream one:
git fetch upstream
git checkout main
git merge upstream/main
git push origin main
Guidelines for upstream contributions
- Remove all product-specific references, naming, and configuration.
- Follow the existing module patterns: Clean Architecture layers, strongly-typed IDs, Result pattern.
- Include tests matching the upstream coverage standards (90% minimum).
- Integration events go in
Shared.Contracts. Domain logic stays within the module. - Update documentation in
docs/if adding a new module or significant feature.
Checklist (Approach A)
- [ ] Fork created and cloned
- [ ]
packages/styles/branding.jsoncustomized with your product identity - [ ]
api/src/Wallow.Api/appsettings.jsonconfigured (SMTP, OpenTelemetry service name, connection strings) - [ ]
api/seed.jsonconfigured with your bootstrap organization and admin - [ ] Merge driver activated (
git config merge.ours.driver true) - [ ]
dotnet build api/Wallow.slnxsucceeds - [ ]
./scripts/run-tests.shpasses - [ ] Upstream remote added for future syncing
- [ ] Module toggles configured in
FeatureManagementsection
Checklist (Approach B -- Full Rename)
- [ ] All
Wallow.*references renamed toYourProduct.* - [ ]
api/Wallow.slnxrenamed and project paths updated - [ ] Docker Compose configuration updated
- [ ] CI/CD workflows updated
- [ ]
<ContainerRepository>properties updated in the three publishable projects - [ ] React app Dockerfiles checked (
apps/wallow-web/,apps/wallow-auth/) - [ ]
dotnet build api/YourProduct.slnxsucceeds - [ ]
./scripts/run-tests.shpasses