Index · Additional notes
11 min read- Additional notes
- Database access and migrations
- .NET 9 (current pattern in this codebase)
- .NET 9 + Native AOT
- Go (sqlc + pgx + goose)
- Redis access
- .NET (current — `StackExchange.Redis`)
- .NET AOT
- Go (`github.com/redis/go-redis/v9`)
- Migration workflow comparison
- Trade-off summary
- Full-stack capability matrix
- Reading the matrix
- Recommended strategy for this portfolio
- Why not AOT?
- Pure-Go services: what that means in practice
- Custom shared modules to build (incrementally, as needed)
- First Go service candidate
- Interview talking points
Additional notes
Database access and migrations
.NET 9 (current pattern in this codebase)
EF Core via AddDbContext, idempotent MigrateAsync on startup, schema generated from C# entity model.
// Registration
builder.Services.AddDbContext<IdentityDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("IdentityDb")));
// Startup migration (idempotent — no-op when __EFMigrationsHistory is current)
await dbContext.Database.MigrateAsync();
// Query
var tenant = await db.Tenants
.Where(t => t.Slug == slug)
.FirstOrDefaultAsync();
// Generate migration (design-time)
// dotnet ef migrations add AddOnboardingState
Strengths: rich LINQ, change tracking, navigation properties, automatic migration history. Cost: heaviest of the three at runtime.
.NET 9 + Native AOT
EF Core works under AOT but with real constraints:
- No runtime model building — the model must be statically discoverable; some
OnModelCreatingpatterns using reflection break JsonSerializerContextsource generators required for any JSON serialization- No lazy loading proxies (they're reflection-emit based)
- Design-time tooling stays on the JIT host —
dotnet ef migrations addruns against a non-AOT project; production startup applies them withMigrateAsyncas usual - Provider gotchas — Npgsql works, but watch enum mappings and
jsonbconverters
// Same call, same behavior at runtime
await dbContext.Database.MigrateAsync();
// You add this — source-generated JSON context for AOT
[JsonSerializable(typeof(TenantDto))]
[JsonSerializable(typeof(List<TenantDto>))]
internal partial class AppJsonContext : JsonSerializerContext { }
Recommendation: keep the migration project as a regular (non-AOT) class library; only the API host is AOT-published. Two .csproj files, one model.
Go (sqlc + pgx + goose)
The modern Go path is SQL-first, type-generated: you write the SQL, sqlc generates type-safe Go structs and methods. Migrations are plain .sql files versioned by goose or golang-migrate.
-- queries/tenants.sql
-- name: GetTenantBySlug :one
SELECT tenant_id, name, slug, theme_config_json
FROM tenants
WHERE slug = $1;
-- name: UpsertTenantTheme :exec
UPDATE tenants SET theme_config_json = $2 WHERE tenant_id = $1;
// Generated by sqlc — you don't write this
func (q *Queries) GetTenantBySlug(ctx context.Context, slug string) (Tenant, error)
// Usage
pool, _ := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
queries := db.New(pool)
tenant, err := queries.GetTenantBySlug(ctx, "kucy")
if err != nil { return err }
-- migrations/20260523120000_add_onboarding_state.sql
-- +goose Up
ALTER TABLE user_preferences ADD COLUMN onboarding_state TEXT;
-- +goose Down
ALTER TABLE user_preferences DROP COLUMN onboarding_state;
// Startup migration — idempotent, mirrors MigrateAsync semantics
import _ "github.com/pressly/goose/v3"
if err := goose.Up(db, "migrations"); err != nil {
log.Fatal(err)
}
Strengths: SQL is the source of truth (no impedance mismatch), generated code is plain structs (no proxies, no tracker), trivially testable. Cost: no LINQ, you write each query. Three-table joins are SQL, not method chains.
If you want EF-style ORM in Go, GORM exists — but sqlc is the modern idiomatic choice and what most cloud-native Go shops use today.
Redis access
The mental model is identical across all three; only the package changes.
.NET (current — StackExchange.Redis)
public class TenantThemeCacheService
{
private readonly IConnectionMultiplexer _redis;
private static readonly TimeSpan Ttl = TimeSpan.FromMinutes(15);
public async Task<string?> GetThemeAsync(Guid tenantId)
{
var db = _redis.GetDatabase();
var value = await db.StringGetAsync($"tenant:theme:{tenantId}");
return value.HasValue ? value.ToString() : null;
}
public async Task SetThemeAsync(Guid tenantId, string json)
{
var db = _redis.GetDatabase();
await db.StringSetAsync($"tenant:theme:{tenantId}", json, Ttl);
}
}
.NET AOT
Same code. StackExchange.Redis is reflection-light and AOT-compatible. The only thing you'd watch for is if you serialize complex objects into Redis values — that needs JsonSerializerContext like everywhere else under AOT.
Go (github.com/redis/go-redis/v9)
type TenantThemeCache struct {
rdb *redis.Client
}
const ttl = 15 * time.Minute
func (c *TenantThemeCache) GetTheme(ctx context.Context, tenantID uuid.UUID) (string, error) {
val, err := c.rdb.Get(ctx, fmt.Sprintf("tenant:theme:%s", tenantID)).Result()
if errors.Is(err, redis.Nil) {
return "", nil // cache miss
}
return val, err
}
func (c *TenantThemeCache) SetTheme(ctx context.Context, tenantID uuid.UUID, json string) error {
return c.rdb.Set(ctx, fmt.Sprintf("tenant:theme:%s", tenantID), json, ttl).Err()
}
Note Go's explicit context.Context propagation everywhere — the cancellation/timeout story is enforced by the type system, not by convention. Once you adjust to it, you'll wish C# made it as visible.
Migration workflow comparison
| Step | .NET (EF Core) | .NET AOT | Go (goose) |
|---|---|---|---|
| Add migration | dotnet ef migrations add Name | Same (run on JIT design-time host) | goose create name sql |
| Apply at startup | Database.MigrateAsync() | Same | goose.Up(db, "migrations") |
| Roll back | dotnet ef database update PrevName | Same | goose down |
| Versioning table | __EFMigrationsHistory | Same | goose_db_version |
| Schema source of truth | C# entity model | C# entity model | SQL files |
| Drift detection | Snapshot file in git | Same | goose status or manual diff |
| Multi-environment | Connection-string-driven | Same | Connection-string-driven |
| Idempotent re-apply | Yes | Yes | Yes |
Trade-off summary
| Concern | .NET default | .NET AOT | Go |
|---|---|---|---|
| Idle memory per service | High (150 MB) | Medium (45 MB) | Low (15 MB) |
| Cold start | Slow | Fast | Fast |
| Library ecosystem | Best | Most things, some sharp edges | Different but excellent for cloud-native |
| ORM ergonomics | Best (EF Core) | EF Core w/ caveats | SQL-first (sqlc) — different paradigm |
| Migration tooling | First-class (EF Core CLI) | First-class (design-time on JIT host) | First-class (goose / golang-migrate) |
| Redis client | StackExchange.Redis | Same | go-redis (same shape) |
| Reuse of existing NuGets (DomainCore, Logging.Client) | Full | Most | None — rebuild |
| Per-service rewrite cost | Zero | Small (build config + serializer contexts) | Large (new code, but small surface area) |
| Concurrency model | async/await + Task | Same | goroutines + channels (lighter) |
| First-week productivity for a C# dev | 10/10 | 9/10 | 5/10, 8/10 after a week |
Full-stack capability matrix
The non-negotiable: any candidate runtime must support everything in the current production stack — queues, Redis, DB, OIDC, observability, validation, multi-tenancy. Memory savings mean nothing if you lose a primitive you depend on.
| Capability | .NET 9 (today) | .NET 9 + Native AOT | Go |
|---|---|---|---|
| HTTP framework | FastEndpoints | FastEndpoints (PublishAot=true, source-gen mode) — supported | chi / echo / gin (mature, std-lib compatible) |
| Postgres driver | Npgsql via EF Core | Npgsql + EF Core (no lazy loading, no proxies, no runtime model building) | pgx (best-in-class) |
| ORM / data access | EF Core (LINQ, change tracking, navigation props) | EF Core with caveats — for hot paths, drop to Dapper-style raw SQL with source-gen mappers | sqlc (SQL-first, type-generated) or GORM (EF-like) |
| Migrations | EF Core CLI + MigrateAsync() on startup | Same — keep design-time on a non-AOT project | goose or golang-migrate — plain SQL files, idempotent up/down |
| Redis | StackExchange.Redis (IConnectionMultiplexer) | Same package — reflection-light, AOT-clean | redis/go-redis/v9 (same shape, ctx-aware) |
| Message bus (RabbitMQ) | MassTransit (consumers, sagas, retry/redeliver, outbox) | MassTransit AOT support is partial — runtime topology building uses reflection. Safer path: plain RabbitMQ.Client with hand-rolled consumer base classes | amqp091-go (direct AMQP) — no MassTransit equivalent, you build patterns yourself or use watermill for higher-level abstractions |
| Sagas / outbox | MassTransit Sagas + EF Core Outbox | Limited under AOT — likely build a thin custom layer | watermill has outbox patterns; otherwise hand-rolled (it's ~200 lines) |
| OIDC / JWT (Keycloak) | Microsoft.AspNetCore.Authentication.JwtBearer | Same — fully AOT-compatible | coreos/go-oidc + golang-jwt/jwt (mature, used by Kubernetes itself) |
| Validation | FluentValidation | Mostly works — some reflection-based rule discovery loses; explicit registration is the AOT-safe path | go-playground/validator (struct tag based) or hand-rolled |
| JSON serialization | System.Text.Json (default) | System.Text.Json with JsonSerializerContext source generators — mandatory under AOT | encoding/json (std lib) or goccy/go-json (faster) |
| Structured logging | Serilog → Loki | Serilog AOT-compatible; some sinks need attention | zap or slog (1.21+ std lib) → Loki via promtail or direct push |
| Metrics | prometheus-net / OpenTelemetry.Metrics | Both AOT-compatible | prometheus/client_golang (the reference implementation — written in Go) |
| Distributed tracing | OpenTelemetry .NET SDK | AOT-compatible (current OTel SDK supports trimming) | OpenTelemetry Go SDK (mature, OTel itself is largely Go) |
| Health checks | AspNetCore.HealthChecks | Compatible | std lib + small handler, or tavsec/gin-healthcheck |
| HTTP client | HttpClient + IHttpClientFactory | Same | net/http + custom transport |
| Background workers | IHostedService / BackgroundService | Same | goroutine + context.Context + ticker — natively simpler |
| Dependency injection | Microsoft.Extensions.DependencyInjection | Same — DI is AOT-compatible but reflection-based scans need explicit registration | Constructor-call DI by convention — Go intentionally has no DI container (you pass structs). google/wire for compile-time DI if you want it. |
| Multi-tenancy | Your MultiTenancy.Abstractions NuGet (ICurrentTenantService, query filters) | Same package — should be AOT-clean, verify query filter reflection | Rebuild as a Go module — pattern is identical, ~200 LOC: middleware extracts tenant, stores in context.Context, queries scope on tenant_id |
SMTP via MailKit | AOT-compatible | go-mail or net/smtp (std lib) | |
| OpenAPI / Swagger | FastEndpoints.Swagger | Compatible | swaggo/swag (annotation-driven) or oapi-codegen (spec-first) |
| Your custom NuGets (DomainCore, Logging.Client, Branding.AspNetCore, Bff.AspNetCore, Security.Claims) | Used everywhere | Audit each for AOT-readiness — DomainCore's BaseEntity should be clean; Logging.Client wraps Serilog so likely OK; Bff.AspNetCore needs explicit review of its OIDC middleware | Rebuild as Go modules — net new work. Cost is real, but each is small (a few hundred LOC) and the rebuild is good portfolio output. |
Reading the matrix
- Native AOT is a "stay" play — every capability you use today has an AOT-compatible answer, but several need explicit (vs reflection-based) registration. The work is "one-time audit per service" not "rewrite."
- Go is a "leave" play for new services — every capability has a first-class Go answer, often better (Prometheus client, OpenTelemetry, OIDC libs are Go-native), but your custom shared NuGets don't exist there and need rebuilding. Worth it for a new greenfield service; not worth it to migrate the existing fleet.
- MassTransit is the one real risk under AOT — sagas + outbox + topology building all use reflection heavily. If a service depends on MassTransit beyond basic publish/consume, that service should stay on JIT .NET or move to Go (with a hand-rolled or
watermill-based pattern). Document this gap before committing.
Recommended strategy for this portfolio
Two-runtime fleet: default .NET 9 + pure Go. AOT skipped.
- Keep the existing .NET fleet on default JIT. Accept the per-service memory floor as a known cost. No rewrites of working code. Server GC where the box is dedicated, Workstation GC (
DOTNET_SYSTEM_GC_SERVER=0) where many services share a host — the current setup is correct. - Write all new services in pure Go when footprint, cold start, or cloud-native ecosystem fit matters more than reuse of the existing shared NuGets. Greenfield, no retrofit, no library-by-library AOT audit. The "polyglot, picks the right tool" story is also a stronger consultancy signal than "we used .NET trimming tricks."
- Migrate an existing .NET service to Go only when it's being substantially rewritten anyway — don't proactively rewrite working code.
- Rust only for genuinely CPU-bound services (image pipeline, analytics aggregator). Not for CRUD.
Why not AOT?
AOT itself is not the problem. Xamarin/MAUI have shipped AOT-compiled C# for over a decade; the runtime is mature and the codegen is solid. What's hacky is retrofitting AOT onto a codebase built for JIT. EF Core, MassTransit, AutoMapper, and a long tail of OSS libraries were designed around runtime reflection — forcing them through trimming is bolting AOT onto a JIT mental model, and it shows:
- Permanent audit tax — every NuGet upgrade becomes "does this still trim clean?" That cost compounds across services and years.
- "Works except…" caveats everywhere — EF Core docs, MassTransit docs, half the ecosystem. Each caveat is a place future-you can get bitten.
- Some breakages only surface at runtime as
MissingMethodException— trim warnings catch most issues at build time, but not all. - MassTransit specifically is high-reflection (sagas, outbox, runtime topology building) and its AOT story is incomplete. Several services in this portfolio lean hard on MassTransit.
- Consultancy positioning is weaker — "AOT with caveats" is a harder story to sell to clients than "Go where small-and-fast matters, .NET where ecosystem and team velocity matter." Clean tool boundaries beat clever tool stretching.
- The instinctive "this feels like a hack" reaction is data. It's the right reaction to a retrofit, not paranoia.
The cleaner alternative for services where memory or cold-start actually drives the decision: write them in Go from day one. A runtime designed around small-and-fast, with libraries built for that environment. Greenfield clean beats retrofit clever.
If circumstances change later — MassTransit ships first-class AOT support, or .NET's ecosystem completes the trimming transition — this decision can be revisited. For now, the two-runtime fleet stays.
Pure-Go services: what that means in practice
Every new Go service ships with this stack — mapping one-to-one onto what the current .NET services use:
| Capability | Go package |
|---|---|
| HTTP framework | go-chi/chi or labstack/echo |
| Postgres driver | jackc/pgx/v5 (used directly + under sqlc) |
| Type-safe queries | sqlc-dev/sqlc (SQL-first, generates Go) |
| Migrations | pressly/goose (SQL files, idempotent up/down) |
| Redis | redis/go-redis/v9 |
| RabbitMQ | rabbitmq/amqp091-go (direct) or ThreeDotsLabs/watermill (higher-level patterns, outbox, retry) |
| OIDC / JWT | coreos/go-oidc/v3 + golang-jwt/jwt/v5 |
| Validation | go-playground/validator/v10 (struct-tag based) |
| Logging | log/slog (1.21+ std lib) → Loki via Promtail or direct push |
| Metrics | prometheus/client_golang (the reference implementation) |
| Tracing | go.opentelemetry.io/otel |
| HTTP client | net/http + custom transport |
| Background workers | goroutine + context.Context + time.Ticker |
wneessen/go-mail or net/smtp (std lib) | |
| OpenAPI | swaggo/swag (annotations) or oapi-codegen (spec-first) |
| Tests | std lib testing + stretchr/testify |
| Container | distroless or scratch base, multi-stage build, final image 5–20 MB |
Custom shared modules to build (incrementally, as needed)
Your existing .NET shared NuGets don't transfer. Each Go equivalent is small, one-time work, and itself a good portfolio repo:
| .NET NuGet | Go equivalent | Approx LOC |
|---|---|---|
DomainCore | github.com/dloizides/domaincore-go — BaseEntity interface, value objects, domain event interface | ~200 |
Logging.Client | github.com/dloizides/logging-go — slog handler with correlation-id middleware, Loki transport | ~100 |
MultiTenancy.Abstractions | github.com/dloizides/multitenancy-go — context-based CurrentTenant, HTTP middleware, query-scope helpers | ~150 |
Branding.AspNetCore | github.com/dloizides/branding-go — middleware injecting X-Built-By header + optional footer rendering | ~30 |
Bff.AspNetCore | Defer — existing .NET BFFs stay put. Only build a Go BFF if a new product specifically needs one. | — |
Security.Claims | github.com/dloizides/securityclaims-go — JWT claims unmarshalling helpers, role checks | ~100 |
Metrics.Client | Skip — prometheus/client_golang is the reference implementation, wrap thinly if needed | ~50 |
Total greenfield cost for the shared layer is ~600 LOC across 5–6 small modules. Each gets its own GitHub repo on the same pattern as the NuGet repos, published as a Go module (go.dloizides.com/... vanity path optional).
First Go service candidate
Pick a service that:
- Is new or being substantially rewritten anyway (zero throwaway work)
- Does not depend on MassTransit sagas/outbox (keeps the messaging story simple — direct amqp091-go is fine for publish/consume)
- Has clear, narrow scope — one bounded context, one DB schema
- Benefits from low footprint (will be deployed many times, or scales to zero)
Good candidates from the current portfolio: a future analytics aggregator, a webhook receiver, a public-facing read-only API, or a new product's first backend.
Interview talking points
- "Why does per-service memory matter in shared-cluster economics?" — N services × 150 MB floor vs N × 15 MB floor is the difference between 6 and 60 services on a 4 GB host. Architecture choice gives 10×; language choice gives another 10×.
- "Server GC vs Workstation GC tradeoffs" — Server GC trades memory for throughput; Workstation GC trades throughput for memory. Right answer depends on whether the box is dedicated to one service or shared between many.
- "When would you not use AOT?" — Heavy reflection (dynamic plugins, runtime code generation, some older ORMs), or when build-time and binary-size matter more than memory.
- "Why sqlc over GORM?" — sqlc is closer in spirit to F# type providers than to Dapper: SQL stays SQL, types are generated. You catch schema/query mismatches at compile time, not at first request.
- "What does 'polyglot fleet' actually buy a consultancy client?" — Right tool for the job, smaller hosting bill, faster iteration on hot paths, slower drift toward a single-vendor lock-in. The signal is "this person thinks about cost, not just code."