Fisheries VMS Domain · Lesson 15 of ∞ · Second pass · ← Lesson 14

Second pass · design practice, not a tour

Sketching the Documents controller that doesn't exist yet

Lesson 14 found a dead config block and nothing consuming it. This lesson designs what should fill that gap — using only patterns this codebase already proves elsewhere, not invented ones.

World practice: the incoming/clean/quarantine pattern

A file-upload pipeline that has to be trustworthy — not just functional — follows a well-established shape regardless of stack: land the upload in an untrusted staging area, scan it asynchronously, and only promote it to somewhere the rest of the system can read from if it passes. Concretely: three storage prefixes (incoming, clean, quarantine), an event that fires on upload, a worker that scans and moves the object, and a rule that nothing downstream ever reads directly from incoming.

Sources: MakerX — Scanning uploaded files in S3 buckets for malware, AverageDevs — Building a secure file upload pipeline with virus scanning and presigned URLs.

Translating that pattern into e-Boat's actual shape

e-Boat isn't a serverless stack (no Lambda, no S3 event notifications) — it's a monolith with an internal event bus already doing exactly this kind of decoupled fan-out. Lesson 5 covered it: GeofenceEvaluator, the DB writer, and the live map all consume one telemetry stream independently. The same shape fits here directly — a DocumentUploaded domain event replaces the S3 event notification, and a new background worker replaces the Lambda:

DocumentsController.Upload()
   │  writes Document row (ScanStatus = Pending)
   │  puts bytes into MinIO "incoming/" prefix
   │  publishes eboat.documents.uploaded
   ▼
DocumentScanService (BackgroundService)
   │  runs ClamAV against the object
   │  moves it to "clean/" or "quarantine/"
   │  updates Document.ScanStatus
   └─▶ publishes eboat.documents.scanned ──▶ EBoat.Notifications
                                              (inspector gets a Warning-tier alert if infected)

That last arrow isn't speculative wiring — NotificationMessage (Lesson 14) already carries a nullable RelatedViolationId, i.e. the DTO was already built expecting to reference something outside itself. A RelatedDocumentId alongside it would follow the identical shape.

The entity, in this codebase's exact convention

Only two entities exist today — Vessel and VesselPosition — each with an IEntityTypeConfiguration<T> class rather than data annotations. A Document entity follows the same shape as VesselConfiguration, including the FK style proven by VesselPositionConfiguration:

public class DocumentConfiguration : IEntityTypeConfiguration<Document>
{
    public void Configure(EntityTypeBuilder<Document> builder)
    {
        builder.HasKey(d => d.Id);
        builder.Property(d => d.FileName).HasMaxLength(256).IsRequired();
        builder.Property(d => d.ContentType).HasMaxLength(128).IsRequired();
        builder.Property(d => d.ChecksumSha256).HasMaxLength(64).IsRequired();
        builder.Property(d => d.ScanStatus).HasConversion<string>();
        builder.Property(d => d.UploadedAt).HasDefaultValueSql("now()");

        builder.HasOne(d => d.UploadedBy)
            .WithMany()
            .HasForeignKey(d => d.UploadedByUserId)
            .OnDelete(DeleteBehavior.Restrict);
    }
}

The naming translation (FileNamefile_name at the database level) is handled centrally by .UseSnakeCaseNamingConvention() in EBoat.Data/DependencyInjection.cs — nothing in the entity or its configuration needs to know about it.

One FK that can't be written yet

A natural field would be ViolationId, linking a document to the violation it's evidence for — the chain-of-custody link from Lesson 12. It can't be added as a real foreign key today: no Violation entity exists in the codebase yet, only a ViolationCategory enum in EBoat.RuleEngine and a NATS subject name. This is a concrete, current example of Lesson 14's "designed, not yet built" pattern showing up one layer down — Documents can't fully attach to Violations until Violations itself is a table, not just a concept.

The controller and the DI convention it has to pick

Two DI patterns coexist deliberately in this codebase, and a Documents controller has to pick the one that matches its position: EBoat.Data/DependencyInjection.cs states it directly — "Controllers take EBoatDbContext and get one per request... Background consumers ... take IDbContextFactory<TContext>." MapController demonstrates the controller side with primary-constructor injection; PositionArchiveService demonstrates the background-worker side. A Documents controller (request-scoped) takes the scoped context; the scan worker (long-lived singleton) takes the factory — getting this backwards is exactly the "long-lived context accumulates tracked entities until the process runs out of memory" failure the doc comment warns about.

// Controller — request-scoped, mirrors MapController
public sealed class DocumentsController(
    EBoatDbContext db, IDocumentStorage storage, ILogger<DocumentsController> logger)
    : HtmxController
{
    [Authorize(Policy = AuthPolicies.RequireInspector)]
    [HttpPost]
    public async Task<IActionResult> Upload(IFormFile file, CancellationToken ct)
    {
        // validate against StorageOptions (size, extension) — see below
        // write Document row, ScanStatus = Pending
        // storage.PutAsync("incoming/" + document.Id, file)
        // publish eboat.documents.uploaded
        return Screen(page: "Index", fragment: "DocumentRow", model: document);
    }
}

Two things that don't exist yet and block this from compiling

Check your recall

Something unclear, or want to go deeper on any term here? Ask the agent that built this lesson — it's your teacher for this workspace, not just a lesson generator.