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:
- Command comes in, events are appended, projection is updated
- After the write completes, the hub notifies all clients subscribed to that schedule
- 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





