skilj 0.2.0

Event sourcing on Postgres with Dynamic Consistency Boundaries: consistency per command instead of per aggregate, with GraphQL and REST APIs generated from your types.
Documentation

SkilJ

crates.io docs.rs CI MSRV 1.95 license: MIT OR Apache-2.0

skilj is a Rust library for building event-sourced applications backed by Postgres. You define your domain as events (facts that happened) and commands (requests to make something happen), and skilj handles storage, consistency checking, and exposing it all over GraphQL and REST — so you can focus on the domain logic itself.

Try it in a minute, with nothing but Rust installed (no database to set up):

cargo install cargo-generate
cargo generate --git https://github.com/gklijs/SkilJ.git templates/skilj-template --name my-wallet
cd my-wallet && cargo run --bin server

A generated skilj project starting with its own embedded Postgres, then accepting a deposit, rejecting a withdrawal larger than the balance, and accepting a smaller one

Why not classic aggregates?

Most event-sourced libraries make you pick one fixed "aggregate" boundary up front (an Account, an Order) and every command for that aggregate replays its entire history. skilj uses a Dynamic Consistency Boundary (DCB) instead: events and commands carry tags (e.g. wallet: "w1"), and a command's consistency check spans exactly the tagged events it needs — no more, no less. A command that touches two things at once (say, enrolling a student in a course) can check both their histories in one atomic decision, without either one having to "own" the other.

Personal data fits the same model. A payload field can be encrypted per person, so forgetting the person makes it unreadable, and a tag can be blind: a keyed hash of the value instead of the value. That's how "an email address is registered once" stays an ordinary consistency check, without the address ever being stored in the clear - see docs/architecture.md §205.

Why skilj?

The name first: skilj is an anagram of Klijs, the author's last name, and in Swedish skilja (stem skilj) means to separate. That's most of the design. A command is decided against only the events it shares tags with, kept apart from everything else. Bounded contexts never contend with each other. Personal data is kept separate from the event log, so it can be forgotten without rewriting history. Reads are served by projections built separately from the write path.

What you get in one library on one Postgres database, without running a separate event store:

  • Consistency per command, not per aggregate (see above), with gapless sequence numbers per bounded context.
  • GraphQL and REST APIs, generated from your registered types. The GraphQL schema is tailored to each caller's grants, can serve as an Apollo Federation subgraph, and has live subscriptions.
  • Personal data handling built in: sensitive fields are encrypted per subject, and forgetSubject makes them unreadable. Blind tags check uniqueness without storing the value.
  • The parts an event-sourced system usually builds itself: projections, snapshots, deadlines and recurring schedules, idempotency keys, command approval, upcasting, parked deliveries with redrive, and bridges to Kafka, AMQP 1.0, NATS JetStream and Temporal.

When it's probably not the right choice:

  • You can't or won't run Postgres. skilj is Postgres only, by design.
  • One bounded context needs thousands of commands per second. Commands for the same bounded context are serialized through one lock (group-committed). Measured rates are in the hundreds per second per context, and contexts scale independently; see docs/performance.md.
  • You want aggregates, or a polyglot event store that services in other languages append to directly. Other services reach skilj over GraphQL, REST or a broker bridge, not by writing to its tables.
  • You need a vendor's support or an independent audit. See "Status and stability" below.

A quick look

This is trimmed from templates/skilj-template/src/wallet.rs — see that file for the complete, running version:

// An event: something that happened, tagged by which wallet it belongs to.
#[derive(Serialize, Deserialize, JsonSchema)]
struct WithdrawnPayload { wallet_id: String, amount: i64 }

struct Withdrawn;

#[auto_register(BOUNDED_CONTEXT)]
impl EventType for Withdrawn {
    type Payload = WithdrawnPayload;
    const NAME: &'static str = "Withdrawn";
    fn tag_mappings() -> Vec<TagMapping> {
        vec![TagMapping::new("wallet", "wallet_id")]
    }
}

// A command: a request, decided against only the events sharing its tags -
// here, every Deposited/Withdrawn event for this one wallet, nothing else.
struct Withdraw;

#[auto_register(BOUNDED_CONTEXT)]
impl CommandType for Withdraw {
    type Payload = WithdrawPayload;
    type Event = WalletEvent;
    const NAME: &'static str = "Withdraw";
    fn tag_mappings() -> Vec<TagMapping> { /* same as above */ }

    fn decide(payload: &Self::Payload, matching_events: &[Self::Event]) -> CommandDecision {
        let balance = balance_of(matching_events);
        if payload.amount > balance {
            return CommandDecision::Rejected {
                reason: format!("insufficient funds: balance is {balance}"),
                kind: "insufficient_funds".into(),
            };
        }
        CommandDecision::Accepted {
            events: vec![EventSpec {
                event_type: "Withdrawn".into(),
                payload: serde_json::json!({ "wallet_id": payload.wallet_id, "amount": payload.amount }),
            }],
        }
    }
}

That's it — no separate storage layer to wire up, no aggregate repository to implement. Add a Projection (also shown in wallet.rs) when you need a read-optimised view instead of replaying events on every query.

Getting started

The fastest way to try it is to generate a small, working project:

cargo generate --git https://github.com/gklijs/SkilJ.git templates/skilj-template

That gives you a runnable server with one bounded context (the wallet example above) already wired up. Without DATABASE_URL it starts an embedded Postgres of its own, keeping its data between runs (docs/architecture.md §216); set DATABASE_URL to use your own Postgres instead. See its own README for more.

To add skilj to an existing project instead:

[dependencies]
skilj = "0.2"

skilj-demo in this repository is a larger worked example (two bounded contexts: banking and courses) if you want to see more before committing. It also has a supported single-user local mode: cargo run -p skilj-demo --bin server -- --local starts an embedded Postgres in a persistent data directory, with no DATABASE_URL needed (docs/architecture.md §216).

Status and stability

skilj is pre-1.0 and maintained by one person. What a release promises:

  • Semantic versioning, Cargo-style. Within a 0.x series, patch releases (0.1.0 → 0.1.1) are compatible; a breaking change to the Rust API, the GraphQL or REST contract, or configuration needs the next minor release (0.2.0), and is listed in CHANGELOG.md. Public enums of the library crates are #[non_exhaustive], so a new variant - an error, a status, an outcome - can come in a patch release: match them with a _ arm. GraphQL and REST enums may likewise gain a value; treat one you don't know as unknown rather than failing.
  • Database upgrades are automatic and forward-only. Startup patches an older schema in place; an upgrade from the very first release's schema is part of the test suite (docs/architecture.md §159). Take a backup before upgrading production; there is no tool to go back to an older schema.
  • Minimum supported Rust version: 1.95, checked in CI. Raising it is a minor-release change.
  • Which crates the promise covers. skilj, skilj-core, skilj-rest, skilj-codegen, skilj-test-fixture and the bridges (skilj-kafka, skilj-amqp, skilj-nats, skilj-temporal). skilj-tui and skilj-inspector are tools: their command line is covered, their Rust API is not. skilj-graphql, skilj-macros, skilj-retry and skilj-bridge are internal, published because other crates need them: a patch release never breaks them either, but they may change in any minor release without migration notes. The GraphQL contract itself is covered, like REST's; use skilj-graphql and the macros through skilj's router and re-exports, and the other two not at all. Items hidden from the documentation (#[doc(hidden)]) aren't covered either; skilj-core's db module is the largest (docs/architecture.md §220).
  • Security. Only the latest release gets fixes; see SECURITY.md. The design has been through many rounds of internal review, recorded in docs/architecture.md, but has had no independent third-party audit.

Crates in this workspace

Crate What it is
skilj The main library - start here. A thin facade over skilj-core/skilj-graphql/skilj-rest.
skilj-core The domain engine: events, commands, projections, and persistence. No web framework dependency.
skilj-graphql The GraphQL surface Skilj::graphql_router() serves - optionally an Apollo Federation subgraph for Apollo Router, Hive Router or Cosmo to compose (docs/architecture.md §194). Internal: use it through skilj.
skilj-rest The REST surface - authenticated routes for agents and automated callers.
skilj-codegen Optional: generate event/command boilerplate from a declarative .skilj.toml file instead of hand-writing it.
skilj-tui cargo install skilj-tui - a terminal console (GraphQL client) for browsing and operating a running deployment.
skilj-inspector cargo install skilj-inspector - a terminal console that reads straight from Postgres, for when the GraphQL server isn't running.
skilj-temporal Optional: a bridge from skilj's own event stream to Temporal - start or signal a workflow execution correlated by a DCB tag. See docs/architecture.md §34.
skilj-bridge The skilj side the three message-broker bridges share - consume/ack, inbound record/trigger, parked-delivery reports, partition ownership. A dependency of the bridges, not usually used directly. See docs/architecture.md §165.
skilj-kafka Optional: a bridge between skilj's own event stream and Kafka, both directions - produce mapped events to a topic, consume a topic into ExternalEventIngestion/CommandTrigger. See docs/architecture.md §40.
skilj-amqp Optional: skilj-kafka's own sibling for any AMQP 1.0 broker - Solace PubSub+, Azure Service Bus, ActiveMQ Artemis. See docs/architecture.md §41.
skilj-nats Optional: a third bridge sibling, for NATS JetStream. See docs/architecture.md §42.
skilj-test-fixture Optional dev-dependency: a given/when/then test harness for your own CommandType::decide()/Projection::project() - no database, no HTTP. See docs/architecture.md §45.
skilj-retry Internal: the backoff/attempt-cap policy skilj-core's routes and the broker bridges share. A dependency, not used directly.
skilj-macros Internal proc-macros, re-exported through skilj-core/skilj (not uniformly - each macro picks whichever crate it applies to) - you won't normally add this directly.

Documentation

  • specs/skilj.allium - the behavioural specification: what the system guarantees, independent of the Rust code.
  • docs/architecture.md - how that's actually built in Rust, including the reasoning behind each design decision. It's written as a running design log, so later sections assume you've read the earlier ones.
  • docs/authorization.md - diagrams of the authorization flows: JWT and token authentication, grants, scopes, capabilities, field-level protection and the access trail.
  • docs/performance.md - what limits command throughput, measured numbers, and the batching knobs.
  • docs/rest-event-reading.md - the three ways to consume the REST event stream and when to use each.
  • docs/temporal-integration.md - pairing skilj with Temporal for long-running, cross-system processes.

Contributing

Bug reports and pull requests are welcome - see CONTRIBUTING.md for how to build, test, and submit changes, and CODE_OF_CONDUCT.md for community expectations. Found a security issue? See SECURITY.md instead of opening a public issue.

License

Licensed under either of

at your option.

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this project, as defined in the Apache-2.0 license, shall be dual-licensed as above, without any additional terms or conditions.