Skip to main content

What is FactStore?

· 6 min read
Domenic Cassisi
FactStore Maintainer

FactStore is an event store for event-sourced applications that lets you choose the consistency boundary per operation, instead of baking it into your stream design. It appends, retrieves, and streams immutable facts across one or many stores.

That second half is the part I actually care about, and it's the reason this project exists. This post explains what that means and why I think it's worth building.

Do you need another event store?

For most teams today, honestly: no.

There are good, mature event stores already: KurrentDB is purpose-built and battle-tested, Marten is excellent if you're on .NET and PostgreSQL, and Axon gives you a full framework around the JVM. If you need to ship an event-sourced system this quarter, use one of those. I mean that literally.

FactStore exists because I kept running into the same structural problem in every one of them, and I wanted to find out what an event store looks like if you take that problem as the starting point rather than as something to work around.

A quick recap

Event sourcing is nothing new. It was described by Martin Fowler and popularized by Greg Young many years ago: you store events as the source of truth, rebuild state from them to make the next decision, and capture that decision as a new event.

I call those events facts, and the name is deliberate. A fact describes something that has happened in the domain. It cannot be undone, only compensated. That is the whole reason it's safe to keep it forever in an append-only log — and it's why the project is called what it is.

The problem: consistency boundaries are decided too early

Mainstream event sourcing was heavily influenced by domain-driven design tactical patterns, particularly the aggregate, which more often than not gets mapped directly onto an event stream. An event stream is a subset of the global log, a small excerpt that one component owns and guards.

The trouble is that these streams must be designed carefully and up front, and they are usually static afterward. Your stream layout is a schema decision. It hardens quickly, and it decides (before you've written any business logic) exactly which facts a given operation is allowed to consider when it makes a decision.

That works until an invariant doesn't respect your boundaries.

A concrete example

Take a rule every system has had at some point: no two users may register with the same email address.

If each user is an aggregate, and each aggregate is a stream, then no single stream can enforce this. The rule spans all of them. The usual answers are:

  • introduce an EmailReservation aggregate, reserve the address first, then register the user, and add a process manager to release reservations that were never used; or
  • keep a uniqueness table in a separate database and write to it transactionally, which means the event store is no longer the only source of truth.

Both work. Both also introduce a concept — "email reservation" — that no domain expert ever mentioned, that exists purely because of how we laid out our streams, and that someone now has to explain in every onboarding session. The complexity is real, but it is not domain complexity. It's an artifact of a boundary we drew too early.

I kept noticing that these conversations got harder to have with business people, not easier. That felt like a signal.

Dynamic Consistency Boundaries

A term was coined for the alternative: Dynamic Consistency Boundaries (DCB), introduced by Sara Pellegrini.

The idea is to stop pinning each fact to exactly one fixed stream. Instead, a fact carries one or more tags, and you describe the consistency boundary at the moment you need it, as a query: combine fact types with tags to select exactly the facts this particular decision depends on. Nothing more, nothing less.

The boundary becomes a property of the operation rather than of the schema. If a new invariant shows up next year that cuts across your data differently, you write a different query. You don't restructure your streams.

If you want the full picture, dcb.events is the place to start.

What that looks like in FactStore

FactStore supports this natively: the same tag query that reads the facts can be attached to the append as a condition. Here's the email rule, in full:

val users = StoreName("users")
val email = mapOf(TagKey("email") to TagValue("ada@example.com"))

// The consistency boundary, written down explicitly:
// every fact that could invalidate this decision.
val query = TagQuery(listOf(
TagTypeItem(types = setOf(FactType("UserRegistered")), tags = email)
))

// Read exactly those facts — no aggregate, no stream, no reservation.
val found = factStore.findByTagQuery(FindByTagQueryRequest(users, query))
if (found is Found && found.facts.isNotEmpty()) return EmailAlreadyTaken

// Append, but only if that query still matches nothing.
val result = factStore.append(AppendRequest(
storeName = users,
facts = listOf(FactInput(
type = FactType("UserRegistered"),
subject = Subject("user/ada"),
payload = """{"email":"ada@example.com"}""".toFactPayload(),
tags = email,
)),
idempotencyKey = IdempotencyKey(),
condition = AppendCondition.TagQueryBased(failIfEventsMatch = query, after = null),
))

The condition is the interesting line. Between the read and the append, another process may have registered the same address. If it did, the store returns AppendConditionViolated and nothing is written. The check and the write are one atomic operation, so the race that the reservation pattern exists to prevent simply isn't there.

The invariant now lives in one place, expressed in the language of the domain, and EmailReservation never has to be invented.

Can you still use aggregates?

This doesn't replace the aggregate style. AppendCondition also has an ExpectedLastFact variant, which is the classic optimistic-concurrency check on a single subject. Both live in the same store, and you pick per operation. That's the part I didn't want to give up: when the traditional approach fits, it should still be available, in the same system, without a migration.

Why I'm building FactStore

Partly because I'm a believer. Event sourcing tends to align much more closely with how businesses actually operate. Most systems are about processes and behavior, which we automate through software, while CRUD flattens the domain-specific language into a restricted create/read/update/delete vocabulary. Along the way I watched event sourcing get conflated with technical machinery that made it look far more complicated than it needs to be, and I'd like to push a little in the other direction.

And partly for a less noble reason: implementing an event store teaches you things that using one never will. Ordering guarantees, idempotency across process boundaries, what happens when two appends race. You can read about all of it, but you don't really know it until you've had to write it down and implement it.

What's next

This blog is where I'll document the rest of it: the design decisions, the things I got wrong, and what building an event store is actually like.

If you'd like to try it in the meantime, the Quick Start is about five minutes end to end.