惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

小众软件
小众软件
博客园_首页
博客园 - 聂微东
T
Tailwind CSS Blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
J
Java Code Geeks
The Cloudflare Blog
aimingoo的专栏
aimingoo的专栏
Martin Fowler
Martin Fowler
D
Docker
人人都是产品经理
人人都是产品经理
WordPress大学
WordPress大学
博客园 - 三生石上(FineUI控件)
Microsoft Azure Blog
Microsoft Azure Blog
Recent Announcements
Recent Announcements
Apple Machine Learning Research
Apple Machine Learning Research
阮一峰的网络日志
阮一峰的网络日志
B
Blog RSS Feed
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
Microsoft Security Blog
Microsoft Security Blog
L
LangChain Blog
Jina AI
Jina AI
博客园 - Franky
D
DataBreaches.Net

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
Just Shipped FilmTailor's Scheduling Feature and Chose Ev...
Andy Robinson · 2026-06-17 · via DEV Community

The history of a film schedule matters as much as its current state. That one insight changed everything about how we built this.


At FilmTailor, we shipped our scheduling feature this week. It lets film productions manage their shoot schedules: strips, cast assignments, scene ordering, the works. It sounds straightforward. It is absolutely not straightforward.

This post covers the architectural choices we made, why we made them, and the honest trade-offs involved. If you've ever been tempted by event sourcing but weren't sure whether it was worth the overhead, hopefully this helps.


What Does the Scheduling Service Actually Do?

Before we talk architecture, a quick primer on the domain.

A "schedule" in film production is a breakdown of the shoot: which scenes get filmed on which days, who's required on set, what equipment is needed. The core unit is a "strip" (think of a physical index card in the old system, representing a single scene). Strips get ordered, reordered, archived, imported from scripts, and annotated with production details.

The key insight that shaped our architecture: schedules change constantly, and the history of those changes matters.

A production coordinator needs to know who reordered the strips on Tuesday afternoon, because that coincided with a cast member being added and now the schedule is wrong. "Just check the database" doesn't help you if you've already overwritten the old order.

That insight led us straight to event sourcing.


Why Event Sourcing? (And Why Not Just CRUD?)

Let's be honest: CRUD over a MediatR pipeline is the path of least resistance in .NET. It's what most developers reach for, and for most problems, it's the right call. You get:

  • A simple mental model (rows go in, rows come out)
  • Straightforward querying
  • Libraries and patterns the whole team already knows

So we had to genuinely justify not doing that.

The case for CRUD

Here's what a standard approach would have looked like:

POST /schedules/{id}/strips/reorder
→ Update strip positions in the database
→ Done

Quick to implement, easy to reason about, well-understood performance characteristics. The database is the state.

Where CRUD falls down for scheduling

The problem is that a schedule isn't just a snapshot, it's a process. Productions run for weeks or months. A schedule might be reordered dozens of times before a shoot day. Questions like "what did this schedule look like three days ago?" or "which user archived this strip?" are not edge cases. They're core to the product.

With CRUD, answering those questions means bolting on an audit log after the fact. You end up with two sources of truth (your main table and your audit table), and they inevitably drift.

With event sourcing, the audit log is the source of truth. Every change is an immutable, timestamped event. The current state is simply what you get when you replay those events.

ScheduleCreated { by: "user-123", at: "2024-06-01T09:00:00Z" }
StripAdded      { stripId: "s-001", position: 0, by: "user-123" }
StripAdded      { stripId: "s-002", position: 1, by: "user-456" }
StripReordered  { stripId: "s-001", newPosition: 1, by: "user-456" }

You can ask "what was the order at 10am?" and get a real answer. You can see who made what change and when. No separate audit infrastructure required.

An underrated benefit: projecting the same events into multiple read models

Here's something that doesn't get talked about enough. Because your events are the ground truth, you're not locked into a single representation of your data. You can project the same event stream into as many materialised views as you like, each shaped for a different purpose.

We currently maintain a ScheduleBoardView (optimised for rendering the drag-and-drop board UI) and a StripFieldSuggestionsView (optimised for autocomplete). Both are built from the same underlying event stream. Adding a third for reporting, or a fourth for a future mobile client with a different data shape, costs almost nothing architecturally.

Even better: if you need to change a read model's structure (say, you want to add a computed field, or you realised the data shape is wrong), you just update the projection code and tell Marten to rebuild. It replays all the events from the beginning and reconstructs the view from scratch. This would be a painful migration script in a CRUD system. With event sourcing, it's a configuration option.

// Marten will rebuild this projection from scratch on next startup
options.Projections.Add<ScheduleBoardProjection>(ProjectionLifecycle.Inline,
    asyncConfiguration: x => x.EnableDocumentTrackingForQuery = true);

This was one of those "oh, this is actually great" moments during development. We changed the shape of ScheduleBoardView twice during the build. Each time, it was a non-event (pun intended).


The Stack: Marten + PostgreSQL

We chose Marten as our event store, running on PostgreSQL.

There are purpose-built event store options out there. The most prominent is Kurrent (formerly EventStoreDB), which is mature, battle-tested, and has excellent tooling - I've personally used it for a project at NHS Wales, with great success. We ruled it out at this stage for a straightforward reason: operational complexity. Kurrent is a separate database engine with its own deployment, monitoring, backup strategy, and failure modes. For a team at our current scale, adding that to the stack felt premature.

That said, it's genuinely on our radar for the future. One of the underappreciated benefits of building on event sourcing from the start is that the events themselves are portable. If we ever outgrow Marten or want Kurrent's more advanced features (persistent subscriptions, projections across multiple streams, that sort of thing), migrating the event log across is fairly mechanical. The events are just JSON records with timestamps. We're not locked in.

For now, Marten gives us most of what we need while staying on infrastructure we already understand.

It's PostgreSQL all the way down. We already use Postgres. Marten stores events as JSONB and gives you a proper event stream abstraction on top. No separate event store to run, monitor, or secure.

Projections are first-class. Marten has a mature projection system that keeps read models up to date automatically. The projection code is just C# — no separate query language to learn.

It's been around long enough to be boring. Boring is good in production infrastructure. Marten is well-maintained, has excellent documentation, and the community is active.

Here's roughly what our aggregate looks like:

public class ScheduleAggregate
{
    public Guid Id { get; private set; }
    public ScheduleStatus Status { get; private set; }
    public List<StripState> Strips { get; private set; } = [];

    // Command handler: validates, then produces events
    public IEnumerable<object> Handle(ReorderStrips command)
    {
        if (Status != ScheduleStatus.Draft)
            throw new ScheduleDomainException("Only draft schedules can be reordered");

        // Validation logic here...

        yield return new StripsReordered(command.NewOrder, command.UserId, DateTimeOffset.UtcNow);
    }

    // Apply method: pure state reconstruction from event
    public void Apply(StripsReordered @event)
    {
        // Update in-memory state based on event
        // No side effects, no DB calls, just state mutation
    }
}

The Handle method validates the command and emits events. The Apply method reconstructs state from events. These two concerns stay completely separate, which makes the business logic surprisingly easy to unit test.


CQRS: Splitting Reads and Writes

Event sourcing pairs naturally with CQRS (Command Query Responsibility Segregation), because once you've committed to storing events rather than state, you need a read model strategy.

Replaying a full event stream every time someone loads a schedule board would be daft. Instead, we use Marten's projection system to maintain a ScheduleBoardView: a pre-computed, read-optimised snapshot that gets updated whenever new events are appended.

public class ScheduleBoardProjection : SingleStreamProjection<ScheduleBoardView>
{
    public void Apply(ScheduleBoardView view, StripAdded @event)
    {
        view.Strips.Add(new StripSummary
        {
            Id = @event.StripId,
            Position = @event.Position,
            // ... other fields
        });
    }

    public void Apply(ScheduleBoardView view, StripsReordered @event)
    {
        // Update positions in the read model
    }
}

We configure these as inline projections, meaning they run synchronously as part of the same transaction that appends the events. The read model is always up to date before the HTTP response goes out. No eventual consistency lag for the client to deal with.

This was a deliberate choice. Async projections are more scalable under extreme load, but they introduce a window where the read model lags behind. Given the collaborative, real-time nature of schedule editing, we didn't want users to submit a change and immediately get a stale read back.

The trade-off: synchronous projection adds a small amount of latency to write operations. We've found this completely acceptable at current scale, and we're monitoring it.


Real-Time Updates with SignalR

Schedules in film production are collaborative. Multiple people might be looking at the same schedule simultaneously. We didn't want someone's reorder to be invisible to everyone else until they refreshed.

We added a SignalR hub that broadcasts to connected clients whenever the schedule changes. The flow is simple:

  1. Command comes in, events are appended, projection is updated
  2. After the write completes, the hub notifies all clients subscribed to that schedule
  3. Clients re-fetch the schedule board (or we push the delta — we're evolving this)

In production, this runs through Azure SignalR Service rather than in-process, which means it scales horizontally without us having to worry about sticky sessions or shared state across container instances.


Honest Reflections: What's Hard About Event Sourcing

We'd be doing you a disservice if we made this sound like pure upside. Here's what we'd warn you about.

The learning curve is real. If your team is used to thinking in rows and tables, the mental shift to "what happened" rather than "what is" takes time. The aggregate pattern, the apply/handle split, the projection lifecycle: these are all new concepts to internalise.

Schema evolution requires care. Events are immutable, which means you can't just change an event's shape and re-run a migration. Marten has event versioning and upcasting support, which we're leaning on, but it's something you need to think about from the start rather than retrofitting.

Querying across aggregates is awkward. Event sourcing is brilliant for single-aggregate queries. Cross-aggregate reporting (for example, "show me all schedules across this organisation with more than 50 strips") requires projections designed for the purpose, or a separate read store. Our organisation-level metrics endpoint required a dedicated projection rather than a simple SQL query.

The event store grows over time. We're not at a scale where this is a problem, but for long-lived, high-activity aggregates, you'd want a snapshot strategy to avoid replaying thousands of events on load. Marten supports snapshots; we haven't needed them yet.


Would We Do It Again?

Yes, for this domain, unambiguously.

The audit trail alone has already paid for the added complexity. We've had two instances during development where a schedule ended up in an unexpected state, and being able to replay the exact sequence of events that led there made debugging trivially easy. With CRUD, we'd have been staring at the current state and guessing.

That said, event sourcing is a tool, not a philosophy. We have other services in the FilmTailor platform that are genuinely just CRUD: user preferences, notification settings, that sort of thing. Those don't need an event history. They use a standard EF Core + MediatR pattern and are simpler for it.

Reach for event sourcing when:

  • The history of changes matters to the business, not just the current state
  • You need auditability without bolting on a separate audit system
  • Your domain has rich, behavioural rules that benefit from an explicit aggregate model
  • You want flexibility to evolve your read models without painful data migrations

Reach for CRUD when:

  • You're managing configuration or reference data
  • The current state is all that matters
  • Speed of development is the priority and the domain is simple

Sorry Not Sorry

If you've read my previous post about Tailwind, you'll know I'm not shy about having opinions on technical choices. This one follows the same spirit.

Are we confident in these architectural decisions? Yes. Did we make them carelessly? No. We looked at the trade-offs, understood what we were taking on, and made a deliberate call based on the domain in front of us. That's not the same as being reckless, it's just being an engineer.

But here's the thing: confident doesn't mean infallible. Event sourcing might bite us in ways we haven't anticipated yet. The projection rebuild story might get complicated at scale. Kurrent might start looking very attractive once the event volume grows. Schema evolution might turn out to be more painful than we've planned for. To be honest, that would be a wonderful problem to have - it'll mean that we have users using the product we've been building for some time.

If any of that happens, we won't be sorry for having made these choices. We'll be glad we made them deliberately, understood why they seemed right at the time, and are therefore in a much better position to understand why they stopped working. That's not failure. That's how a team gets better.

The only architectural decisions worth being sorry for are the ones made without thinking. These weren't.


The scheduling service is live as of this week. If you're building tools for film and TV production and want to chat architecture, or just want to compare notes on Marten in production, find us at filmtailor.ai.

The FilmTailor Engineering Team

Check out how we're building the future of film production