Fisheries VMS Domain · Lesson 15 of ∞ · Second pass · ← Lesson 14
Second pass · design practice, not a tour
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.
Every earlier lesson taught something that exists — in the world, or in e-Boat's own code. This one is different: it's a design exercise, because the thing being taught genuinely isn't built. Every convention cited below is real and quoted from the codebase; the assembly of those conventions into a Documents flow is a sketch, not a description of shipped code. Treat it as practice for reading a gap and knowing what to reach for, not as documentation.
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.
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.
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 (FileName → file_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.
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.
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);
}
}
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.