Ultra-lean .NET 9 backend template for building modular SaaS backends fast. Clean Architecture, EF Core + PostgreSQL, .NET Aspire orchestration, and a simple plugin model so you can add features without touching template code.
- Modular foundation:
IShipMvpModule+ reflection loader (no manual wiring). - Single database: one
AppDbContextthat auto-discovers allIEntityTypeConfiguration<>across your modules. - Orchestration: optional Aspire AppHost (dashboard, logs, traces, service discovery).
- Batteries included: Swagger, health, connection resilience, pgAdmin (via Aspire).
- Read-only by design: consume this repo as a Git submodule; keep your app code separate.
backend/
└─ src/
├─ ShipMvp.Abstractions/ # Contracts only (no ASP.NET deps)
│ └─ IShipMvpModule.cs
├─ ShipMvp.Modularity/ # Module loader (DI + endpoint discovery)
│ └─ ModuleLoader.cs
├─ ShipMvp.Infrastructure/ # EF Core DbContext & conventions
│ └─ AppDbContext.cs # Scans IEntityTypeConfiguration<> in all loaded assemblies
└─ ShipMvp.AppHost/ (optional) # .NET Aspire orchestration (sample runner)
Do not edit template code in consumer apps. Update via submodule bump (see Upgrading).
git submodule add -b stable https://github.com/your-org/shipmvp backend/shipmvp
git submodule update --init --recursiveYou’ll have a read-only folder at backend/shipmvp.
dotnet new webapi -n MyProduct.Api -o apps/backend/MyProduct.Api
dotnet sln add apps/backend/MyProduct.Api/MyProduct.Api.csprojReference template projects:
dotnet add apps/backend/MyProduct.Api/MyProduct.Api.csproj reference \
backend/shipmvp/src/ShipMvp.Infrastructure/ShipMvp.Infrastructure.csproj \
backend/shipmvp/src/ShipMvp.Modularity/ShipMvp.Modularity.csprojusing Microsoft.EntityFrameworkCore;
using ShipMvp.Infrastructure; // AppDbContext
using ShipMvp.Modularity; // ModuleLoader
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(opts =>
{
var cs = builder.Configuration.GetConnectionString("Default")
?? "Host=localhost;Port=5432;Database=shipmvp;Username=postgres;Password=ShipMVPPass123!";
// Put migrations in your app's assembly (or a dedicated *.Migrations project)
opts.UseNpgsql(cs, b => b.MigrationsAssembly("MyProduct.Migrations"));
});
builder.AddDiscoveredModules(); // auto-register modules found in loaded assemblies
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
app.UseSwagger(); app.UseSwaggerUI();
app.MapGet("/", () => Results.Ok("MyProduct API running"));
app.MapDiscoveredModules(); // auto-map endpoints from modules
app.Run();Create a dedicated migrations project in your app and point the context to it.
dotnet new classlib -n MyProduct.Migrations -o apps/backend/MyProduct.Migrations
dotnet sln add apps/backend/MyProduct.Migrations/MyProduct.Migrations.csproj
dotnet add apps/backend/MyProduct.Migrations package Microsoft.EntityFrameworkCore.Design
dotnet add apps/backend/MyProduct.Migrations package Npgsql.EntityFrameworkCore.PostgreSQL
dotnet add apps/backend/MyProduct.Migrations/MyProduct.Migrations.csproj reference \
backend/shipmvp/src/ShipMvp.Infrastructure/ShipMvp.Infrastructure.csprojDesign-time factory (in your API) so EF can scaffold the full model:
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Configuration;
using ShipMvp.Infrastructure;
public class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
{
public AppDbContext CreateDbContext(string[] _)
{
var cfg = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables()
.Build();
var cs = cfg.GetConnectionString("Default")
?? "Host=localhost;Port=5432;Database=shipmvp;Username=postgres;Password=ShipMVPPass123!";
return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(cs, b => b.MigrationsAssembly("MyProduct.Migrations"))
.Options);
}
}Create/apply migrations:
dotnet ef migrations add Initial \
--project apps/backend/MyProduct.Migrations \
--startup-project apps/backend/MyProduct.Api \
--context AppDbContext
dotnet ef database update \
--project apps/backend/MyProduct.Migrations \
--startup-project apps/backend/MyProduct.Api \
--context AppDbContext-
New project:
modules/Billing/Billing.csproj -
Reference Abstractions from the template:
dotnet add modules/Billing/Billing.csproj reference backend/shipmvp/src/ShipMvp.Abstractions/ShipMvp.Abstractions.csproj dotnet add apps/backend/MyProduct.Api/MyProduct.Api.csproj reference modules/Billing/Billing.csproj
-
Implement:
- Entities +
IEntityTypeConfiguration<>(use per-module schema, e.g.,"billing") IShipMvpModulefor DI + endpoints
- Entities +
Interface (from template)
// ShipMvp.Abstractions
public interface IShipMvpModule
{
void ConfigureServices(IServiceCollection services, IConfiguration config);
void MapEndpoints(IEndpointRouteBuilder endpoints);
}Example module (consumer app)
// modules/Billing/Domain/Invoice.cs
public class Invoice
{
public Guid Id { get; set; }
public string CustomerName { get; set; } = default!;
public decimal Amount { get; set; }
public DateTime CreatedAt { get; set; }
}
// modules/Billing/Infrastructure/InvoiceConfig.cs
public sealed class InvoiceConfig : IEntityTypeConfiguration<Invoice>
{
public void Configure(EntityTypeBuilder<Invoice> b)
{
b.ToTable("Invoices", "billing");
b.HasKey(x => x.Id);
b.Property(x => x.CustomerName).HasMaxLength(200).IsRequired();
b.Property(x => x.Amount).HasColumnType("numeric(18,2)");
b.Property(x => x.CreatedAt).HasDefaultValueSql("now()");
}
}
// modules/Billing/BillingModule.cs
public sealed class BillingModule : IShipMvpModule
{
public void ConfigureServices(IServiceCollection services, IConfiguration config) { }
public void MapEndpoints(IEndpointRouteBuilder endpoints)
{
var g = endpoints.MapGroup("/api/billing/invoices");
g.MapGet("/", async (AppDbContext db) =>
await db.Set<Invoice>().OrderByDescending(x => x.CreatedAt).ToListAsync());
g.MapPost("/", async (AppDbContext db, Invoice dto) =>
{
dto.Id = Guid.NewGuid();
dto.CreatedAt = DateTime.UtcNow;
db.Add(dto);
await db.SaveChangesAsync();
return Results.Created($"/api/billing/invoices/{dto.Id}", dto);
});
}
}No manual wiring: the template’s
ModuleLoaderdiscovers and maps modules automatically.
The template includes an AppHost example to orchestrate Postgres, pgAdmin, API, and the Aspire dashboard.
-
Start:
dotnet run --project backend/src/ShipMvp.AppHost/ShipMvp.AppHost.csproj
-
Access:
- Aspire Dashboard:
https://localhost:17152 - API Swagger:
http://localhost:5000/swagger - pgAdmin: via service discovery link in the dashboard
- Aspire Dashboard:
In consumer apps, keep using your own API project; Aspire is optional but handy in development.
This repo should be a submodule in your consumer project. Update it like this:
git -C backend/shipmvp fetch --tags origin
git -C backend/shipmvp checkout stable # or a specific tag, e.g., v0.4.0
git add backend/shipmvp
git commit -m "chore(shipmvp): bump backend template"(Recommend a CI guard to prevent edits within backend/shipmvp/*.)
-
“No model changes detected” when adding entities Ensure your API references the module project so its assembly loads; EF configs must implement
IEntityTypeConfiguration<>. -
Unexpected DROP operations in migrations Removing a module will look like drops to EF. Review the generated migration before applying; delete unintended operations.
-
DB connection issues Verify Postgres is running, connection string is injected, and the container is ready (check logs).
This repository is licensed under the Apache License 2.0. See the LICENSE file at the repository root for details.
License: Apache-2.0
Ship fast, stay modular. This backend template is your foundation; put all of your product logic into your own modules and projects—keep the template clean and updatable.