Index · Additional notes

11 min read
Foundational1 min read
Rapid overview

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 OnModelCreating patterns using reflection break
  • JsonSerializerContext source generators required for any JSON serialization
  • No lazy loading proxies (they're reflection-emit based)
  • Design-time tooling stays on the JIT hostdotnet ef migrations add runs against a non-AOT project; production startup applies them with MigrateAsync as usual
  • Provider gotchas — Npgsql works, but watch enum mappings and jsonb converters
// 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 AOTGo (goose)
Add migrationdotnet ef migrations add NameSame (run on JIT design-time host)goose create name sql
Apply at startupDatabase.MigrateAsync()Samegoose.Up(db, "migrations")
Roll backdotnet ef database update PrevNameSamegoose down
Versioning table__EFMigrationsHistorySamegoose_db_version
Schema source of truthC# entity modelC# entity modelSQL files
Drift detectionSnapshot file in gitSamegoose status or manual diff
Multi-environmentConnection-string-drivenSameConnection-string-driven
Idempotent re-applyYesYesYes

Trade-off summary

Concern.NET default.NET AOTGo
Idle memory per serviceHigh (150 MB)Medium (45 MB)Low (15 MB)
Cold startSlowFastFast
Library ecosystemBestMost things, some sharp edgesDifferent but excellent for cloud-native
ORM ergonomicsBest (EF Core)EF Core w/ caveatsSQL-first (sqlc) — different paradigm
Migration toolingFirst-class (EF Core CLI)First-class (design-time on JIT host)First-class (goose / golang-migrate)
Redis clientStackExchange.RedisSamego-redis (same shape)
Reuse of existing NuGets (DomainCore, Logging.Client)FullMostNone — rebuild
Per-service rewrite costZeroSmall (build config + serializer contexts)Large (new code, but small surface area)
Concurrency modelasync/await + TaskSamegoroutines + channels (lighter)
First-week productivity for a C# dev10/109/105/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 AOTGo
HTTP frameworkFastEndpointsFastEndpoints (PublishAot=true, source-gen mode) — supportedchi / echo / gin (mature, std-lib compatible)
Postgres driverNpgsql via EF CoreNpgsql + EF Core (no lazy loading, no proxies, no runtime model building)pgx (best-in-class)
ORM / data accessEF Core (LINQ, change tracking, navigation props)EF Core with caveats — for hot paths, drop to Dapper-style raw SQL with source-gen mapperssqlc (SQL-first, type-generated) or GORM (EF-like)
MigrationsEF Core CLI + MigrateAsync() on startupSame — keep design-time on a non-AOT projectgoose or golang-migrate — plain SQL files, idempotent up/down
RedisStackExchange.Redis (IConnectionMultiplexer)Same package — reflection-light, AOT-cleanredis/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 classesamqp091-go (direct AMQP) — no MassTransit equivalent, you build patterns yourself or use watermill for higher-level abstractions
Sagas / outboxMassTransit Sagas + EF Core OutboxLimited under AOT — likely build a thin custom layerwatermill has outbox patterns; otherwise hand-rolled (it's ~200 lines)
OIDC / JWT (Keycloak)Microsoft.AspNetCore.Authentication.JwtBearerSame — fully AOT-compatiblecoreos/go-oidc + golang-jwt/jwt (mature, used by Kubernetes itself)
ValidationFluentValidationMostly works — some reflection-based rule discovery loses; explicit registration is the AOT-safe pathgo-playground/validator (struct tag based) or hand-rolled
JSON serializationSystem.Text.Json (default)System.Text.Json with JsonSerializerContext source generators — mandatory under AOTencoding/json (std lib) or goccy/go-json (faster)
Structured loggingSerilog → LokiSerilog AOT-compatible; some sinks need attentionzap or slog (1.21+ std lib) → Loki via promtail or direct push
Metricsprometheus-net / OpenTelemetry.MetricsBoth AOT-compatibleprometheus/client_golang (the reference implementation — written in Go)
Distributed tracingOpenTelemetry .NET SDKAOT-compatible (current OTel SDK supports trimming)OpenTelemetry Go SDK (mature, OTel itself is largely Go)
Health checksAspNetCore.HealthChecksCompatiblestd lib + small handler, or tavsec/gin-healthcheck
HTTP clientHttpClient + IHttpClientFactorySamenet/http + custom transport
Background workersIHostedService / BackgroundServiceSamegoroutine + context.Context + ticker — natively simpler
Dependency injectionMicrosoft.Extensions.DependencyInjectionSame — DI is AOT-compatible but reflection-based scans need explicit registrationConstructor-call DI by convention — Go intentionally has no DI container (you pass structs). google/wire for compile-time DI if you want it.
Multi-tenancyYour MultiTenancy.Abstractions NuGet (ICurrentTenantService, query filters)Same package — should be AOT-clean, verify query filter reflectionRebuild as a Go module — pattern is identical, ~200 LOC: middleware extracts tenant, stores in context.Context, queries scope on tenant_id
EmailSMTP via MailKitAOT-compatiblego-mail or net/smtp (std lib)
OpenAPI / SwaggerFastEndpoints.SwaggerCompatibleswaggo/swag (annotation-driven) or oapi-codegen (spec-first)
Your custom NuGets (DomainCore, Logging.Client, Branding.AspNetCore, Bff.AspNetCore, Security.Claims)Used everywhereAudit 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 middlewareRebuild 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.

Two-runtime fleet: default .NET 9 + pure Go. AOT skipped.

  1. 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.
  2. 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."
  3. Migrate an existing .NET service to Go only when it's being substantially rewritten anyway — don't proactively rewrite working code.
  4. 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:

CapabilityGo package
HTTP frameworkgo-chi/chi or labstack/echo
Postgres driverjackc/pgx/v5 (used directly + under sqlc)
Type-safe queriessqlc-dev/sqlc (SQL-first, generates Go)
Migrationspressly/goose (SQL files, idempotent up/down)
Redisredis/go-redis/v9
RabbitMQrabbitmq/amqp091-go (direct) or ThreeDotsLabs/watermill (higher-level patterns, outbox, retry)
OIDC / JWTcoreos/go-oidc/v3 + golang-jwt/jwt/v5
Validationgo-playground/validator/v10 (struct-tag based)
Logginglog/slog (1.21+ std lib) → Loki via Promtail or direct push
Metricsprometheus/client_golang (the reference implementation)
Tracinggo.opentelemetry.io/otel
HTTP clientnet/http + custom transport
Background workersgoroutine + context.Context + time.Ticker
Emailwneessen/go-mail or net/smtp (std lib)
OpenAPIswaggo/swag (annotations) or oapi-codegen (spec-first)
Testsstd lib testing + stretchr/testify
Containerdistroless 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 NuGetGo equivalentApprox LOC
DomainCoregithub.com/dloizides/domaincore-goBaseEntity interface, value objects, domain event interface~200
Logging.Clientgithub.com/dloizides/logging-go — slog handler with correlation-id middleware, Loki transport~100
MultiTenancy.Abstractionsgithub.com/dloizides/multitenancy-go — context-based CurrentTenant, HTTP middleware, query-scope helpers~150
Branding.AspNetCoregithub.com/dloizides/branding-go — middleware injecting X-Built-By header + optional footer rendering~30
Bff.AspNetCoreDefer — existing .NET BFFs stay put. Only build a Go BFF if a new product specifically needs one.
Security.Claimsgithub.com/dloizides/securityclaims-go — JWT claims unmarshalling helpers, role checks~100
Metrics.ClientSkip — 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."