Files
mic-check/docs/api/03_database_and_repositories.md
James Wampler 6887d09f9c version-0.1 (#1)
Squash and merge of version-0.1

Co-authored-by: James Wampler <james@wamp.dev>
Co-committed-by: James Wampler <james@wamp.dev>
2026-07-01 13:30:07 -07:00

196 lines
6.5 KiB
Markdown
Executable File

# Step 3: Database Schema & EF Core
## Goal
Implement the EF Core `DbContext` and entity configurations in `MicCheck.Infrastructure`, and wire up services in `MicCheck.Api` to use it directly. There is no repository abstraction layer — services inject `MicCheckDbContext` and query it with LINQ.
---
## DbContext
```csharp
// MicCheck.Infrastructure/Data/MicCheckDbContext.cs
public class MicCheckDbContext : DbContext
{
public DbSet<Organization> Organizations => Set<Organization>();
public DbSet<Project> Projects => Set<Project>();
public DbSet<Environment> Environments => Set<Environment>();
public DbSet<Feature> Features => Set<Feature>();
public DbSet<FeatureState> FeatureStates => Set<FeatureState>();
public DbSet<FeatureSegment> FeatureSegments => Set<FeatureSegment>();
public DbSet<Segment> Segments => Set<Segment>();
public DbSet<SegmentRule> SegmentRules => Set<SegmentRule>();
public DbSet<SegmentCondition> SegmentConditions => Set<SegmentCondition>();
public DbSet<Identity> Identities => Set<Identity>();
public DbSet<IdentityTrait> IdentityTraits => Set<IdentityTrait>();
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
public DbSet<Webhook> Webhooks => Set<Webhook>();
public DbSet<WebhookDeliveryLog> WebhookDeliveryLogs => Set<WebhookDeliveryLog>();
public DbSet<ApiKey> ApiKeys => Set<ApiKey>();
public DbSet<Tag> Tags => Set<Tag>();
public DbSet<User> Users => Set<User>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
=> modelBuilder.ApplyConfigurationsFromAssembly(typeof(MicCheckDbContext).Assembly);
}
```
---
## Entity Configurations (EF Core Fluent API)
Each domain entity gets its own `IEntityTypeConfiguration<T>` in `MicCheck.Infrastructure/Data/Configurations/`:
### Key configurations:
**Feature:**
```csharp
entity.Property(f => f.Name).HasMaxLength(150).IsRequired();
entity.Property(f => f.InitialValue).HasMaxLength(20_000);
entity.HasIndex(f => new { f.ProjectId, f.Name }).IsUnique();
```
**FeatureState:**
```csharp
entity.Property(fs => fs.Value).HasMaxLength(20_000);
entity.Property(fs => fs.Version).IsConcurrencyToken();
entity.HasIndex(fs => new { fs.FeatureId, fs.EnvironmentId, fs.IdentityId }).IsUnique();
```
**IdentityTrait:**
```csharp
entity.Property(t => t.Value).HasMaxLength(2_000);
entity.HasIndex(t => new { t.IdentityId, t.Key }).IsUnique();
```
**Environment:**
```csharp
entity.Property(e => e.ApiKey).HasMaxLength(100).IsRequired();
entity.HasIndex(e => e.ApiKey).IsUnique();
```
**ApiKey:**
```csharp
entity.Property(k => k.Key).HasMaxLength(512).IsRequired(); // stores hash
entity.Property(k => k.Prefix).HasMaxLength(8).IsRequired();
entity.HasIndex(k => k.Key).IsUnique();
```
---
## Service Usage Pattern
Services receive `MicCheckDbContext` via constructor injection and query it directly:
```csharp
// MicCheck.Api/Features/FeatureService.cs
public class FeatureService
{
private readonly MicCheckDbContext _db;
private readonly AuditService _auditService;
public FeatureService(MicCheckDbContext db, AuditService auditService)
{
_db = db;
_auditService = auditService;
}
public async Task<Feature> CreateAsync(
int projectId, string name, FeatureType type, string? initialValue,
CancellationToken ct = default)
{
var count = await _db.Features.CountAsync(f => f.ProjectId == projectId, ct);
if (count >= 400)
throw new DomainException("Projects may not have more than 400 features.");
var feature = new Feature { ProjectId = projectId, Name = name, Type = type, InitialValue = initialValue };
_db.Features.Add(feature);
// Auto-create FeatureState for every existing environment
var environments = await _db.Environments
.Where(e => e.ProjectId == projectId)
.ToListAsync(ct);
foreach (var env in environments)
_db.FeatureStates.Add(new FeatureState { FeatureId = feature.Id, EnvironmentId = env.Id });
await _db.SaveChangesAsync(ct);
await _auditService.RecordAsync("Feature", feature.Id.ToString(), "Created", ...);
return feature;
}
}
```
Controllers call the service and map the returned domain object to a response DTO:
```csharp
// MicCheck.Api/Features/FeaturesController.cs
[HttpPost]
public async Task<ActionResult<FeatureResponse>> Create(
int projectId, CreateFeatureRequest request, CancellationToken ct)
{
var feature = await _featureService.CreateAsync(
projectId, request.Name, request.Type, request.InitialValue, ct);
return CreatedAtAction(nameof(GetById), new { projectId, id = feature.Id },
FeatureResponse.From(feature));
}
```
The static `From` method (or a mapping method on the controller) is the only place a domain object becomes a DTO.
---
## Migrations
- Use EF Core code-first migrations
- Migration naming: `{Timestamp}_{DescriptiveName}` (e.g., `20260101_InitialSchema`)
- All migrations in `MicCheck.Infrastructure/Data/Migrations/`
- Run via `dotnet ef migrations add` and `dotnet ef database update`
- The `--project MicCheck.Infrastructure --startup-project MicCheck.Api` flags are required since the DbContext is in Infrastructure
---
## Connection String Configuration
```json
// appsettings.json
{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=miccheck;Username=miccheck;Password=password"
}
}
```
Support `DATABASE_URL` environment variable override (Heroku/Railway style parsing).
---
## Seed Data
Create an initial migration seeder for local development:
- Default organization: "Default"
- Default project: "My Project"
- Default environments: "Development", "Staging", "Production"
---
## Indexes for Performance
Critical indexes beyond unique constraints:
- `FeatureState (EnvironmentId)` — for flag serving
- `FeatureState (IdentityId)` — for identity lookups
- `Identity (EnvironmentId, Identifier)` — unique, for identity resolution
- `AuditLog (OrganizationId, CreatedAt DESC)` — for audit queries
- `Webhook (EnvironmentId)` — for event dispatch
---
## Acceptance Criteria
- [ ] `dotnet ef migrations add InitialSchema` generates a valid migration
- [ ] `dotnet ef database update` applies cleanly against a fresh PostgreSQL instance
- [ ] All unique constraints are enforced at the database level
- [ ] Services use `MicCheckDbContext` directly — no repository interfaces
- [ ] No raw SQL — all queries use EF Core LINQ
- [ ] Integration tests against a real test database verify EF configurations are correct