Getting Started

Your first steps with Drasi — embed the Drasi engine in a .NET console app, connect it to a live PostgreSQL message feed, and build five continuous queries (change detection, a filter, an aggregation, a time-based query, and a cross-source join) that print every change to your terminal.

Imagine you want to react the instant data changes — a new row in a database, a value crossing a threshold, or something that should have changed but didn’t. Drasi lets you express these as continuous queries that stay constantly up to date, with no polling.

This tutorial is the best place to start with Drasi, the .NET library that embeds the Drasi continuous-query engine directly in your process. You’ll build a small console app — no web UI — that connects to a live PostgreSQL table of messages and, step by step, runs five continuous queries that detect changes, filter them, aggregate them, detect the absence of change, and join data across two sources. Every change prints to your terminal in real time.

Unlike the Drasi Server tutorial this is based on, there is no separate server to run, no REST API, and no config files. Instead your app embeds the engine and wires everything up in a few lines of code: it adds the sources, registers the queries, and attaches a C# reaction — a callback that receives every query-result change and prints it. That callback is the embedded-library equivalent of Drasi’s built-in Log Reaction, and it’s where the library’s power shows: you react to changes in your own code.

What you’ll build: a running .NET console app that embeds Drasi, connects to PostgreSQL (and a second HTTP source), and reacts to changes in real time — assembled from Drasi’s three core building blocks:

Sources
Connect to your data sources
Continuous Queries
Define what changes matter
Reactions
Take action automatically
StepWhat You’ll DoTime
Step 1: Set Up Your EnvironmentOpen the dev container (or install .NET 8 + Docker locally)5 min
Step 2: Run the DemoOne command starts PostgreSQL and the console app3 min
Step 3: Watch ChangesInsert, update, and delete messages; watch all-messages react4 min
Step 4: FilterSee how a WHERE clause turns a change feed into a focused signal3 min
Step 5: AggregateCount messages live as data changes3 min
Step 6: Detect InactivityDetect the absence of change over time4 min
Step 7: Join Across SourcesJoin messages with live location data from a second source5 min
How It WorksUnderstand the sources, the five queries, and the C# reaction6 min
Putting It All TogetherRead the whole app — every source, query, and the reaction — in one file3 min

Step 1 of 7: Set Up Your Environment

This tutorial needs Docker (it runs PostgreSQL) and .NET 8. The easiest way to get everything is the dev container, which installs it all for you. You can also run locally.

This repo has several tutorial dev containers. Codespaces does not ask which one to use if you click Create codespace on the default Code menu — pick the configuration explicitly (or use the link below).

  1. GitHub Codespaces: Open this Codespaces link (it selects Drasi .NET — Getting Started Tutorial for you).

    Or from the repo page: Code → Codespaces → … next to Create codespace on main → New with options…, set Dev container configuration to Drasi .NET — Getting Started Tutorial, then create the codespace.

  2. VS Code (local): Open the drasi-dotnet repository and run Dev Containers: Reopen in Container. When prompted for a configuration, choose Drasi .NET — Getting Started Tutorial.

  3. Wait for the container to finish. Its setup script restores the tutorial project.

That’s it — skip ahead to Step 2.

Option B: Run Locally

You’ll need .NET 8 and Docker (for PostgreSQL). You drive changes with docker and curl, which work in any shell. From the repository root, move into the tutorial directory and install its dependencies:

cd tutorials/getting-started
dotnet restore

Drasi ships prebuilt native binaries in the NuGet package, so there is no Rust toolchain to install. dotnet restore pulls Drasi 0.1.0 from nuget.org.

Step 2 of 7: Run the Demo

Everything runs from a single command. In Terminal 1, start the demo:

./scripts/start-demo.sh

./scripts/start-demo.sh starts PostgreSQL (seeded with a Message table of four messages) and then runs the console app in the foreground.

On first start, the app downloads the Drasi plugins it needs — source/postgres, bootstrap/postgres, source/http, and bootstrap/scriptfile. It uses InstallPluginAsync, which resolves each one to the build that matches your platform and this library version (no tags, architecture suffixes, or filenames to work out), then connects to the database, bootstraps the four existing messages, and starts the five continuous queries. When you see this line, it’s ready:

✅ Getting Started is ready — Drasi is watching for changes.

The app prints query changes as they happen — it doesn’t dump the current state on startup. In fact, the first thing you’ll see is a burst of output about 20 seconds in: the inactive-senders query firing because the four seed senders have gone quiet (more on that in Step 6). Leave the app running; you’ll drive all the changes from Terminal 2.

Step 3 of 7: Watch Changes

The first query, all-messages, selects every message and keeps the result set continuously up to date. Let’s change the data and watch it react. In Terminal 2, send a new message:

docker exec getting-started-dotnet-postgres psql -U drasi_user -d getting_started \
  -c "INSERT INTO \"Message\" (\"From\", \"Message\") VALUES ('You', 'My first message');"

That’s a plain SQL INSERT against PostgreSQL, run with psql inside the database container. Watch Terminal 1 — an ADD notification appears instantly:

[drasi] Query 'all-messages' (1 change):
  [ADD]    {"From":"You","Message":"My first message","MessageId":5}

Now update that message’s text:

docker exec getting-started-dotnet-postgres psql -U drasi_user -d getting_started \
  -c "UPDATE \"Message\" SET \"Message\" = 'My first UPDATED message' WHERE \"MessageId\" = 5;"

Drasi reports the transition — the row’s before and after — because it maintains the query’s result set, not just the latest change:

[drasi] Query 'all-messages' (1 change):
  [UPDATE] {"From":"You","Message":"My first message","MessageId":5} -> {"From":"You","Message":"My first UPDATED message","MessageId":5}

Finally, delete it:

docker exec getting-started-dotnet-postgres psql -U drasi_user -d getting_started \
  -c "DELETE FROM \"Message\" WHERE \"MessageId\" = 5;"
[drasi] Query 'all-messages' (1 change):
  [DELETE] {"From":"You","Message":"My first UPDATED message","MessageId":5}

With three SQL statements you’ve seen all three change notifications: an ADD when a row entered the result set, an UPDATE showing the row’s before and after, and a DELETE when it left — each printed the instant the database changed, and you didn’t write any code to detect, diff, or deliver them. That’s the continuous query and the reaction doing their job.

Step 4 of 7: Filter

The hello-world-senders query is the same idea with a WHERE clause — it selects only messages whose text is exactly Hello World, returning who sent them. This is the kind of filter that turns a raw change feed into a focused signal.

The seed data already contains one match (Brian Kernighan’s “Hello World”), but the reaction only prints changes, so let’s create one. Send a matching message:

docker exec getting-started-dotnet-postgres psql -U drasi_user -d getting_started \
  -c "INSERT INTO \"Message\" (\"From\", \"Message\") VALUES ('Alice', 'Hello World');"

Two queries react — the message is part of both result sets:

[drasi] Query 'all-messages' (1 change):
  [ADD]    {"From":"Alice","Message":"Hello World","MessageId":6}
[drasi] Query 'hello-world-senders' (1 change):
  [ADD]    {"Id":6,"Sender":"Alice"}

Now send a message that doesn’t match:

docker exec getting-started-dotnet-postgres psql -U drasi_user -d getting_started \
  -c "INSERT INTO \"Message\" (\"From\", \"Message\") VALUES ('Bob', 'Goodbye World');"

Only all-messages reacts — hello-world-senders stays silent, because Bob’s message isn’t part of its result set:

[drasi] Query 'all-messages' (1 change):
  [ADD]    {"From":"Bob","Message":"Goodbye World","MessageId":7}

WHERE clauses control exactly what a query’s result set contains, and therefore what changes generate notifications.

Step 5 of 7: Aggregate

Drasi maintains state across all the data it processes, so a continuous query can compute aggregations — counts, sums, averages — that update automatically as the underlying data changes. The message-counts query counts how many times each unique message text has been sent.

Send another “Hello World”:

docker exec getting-started-dotnet-postgres psql -U drasi_user -d getting_started \
  -c "INSERT INTO \"Message\" (\"From\", \"Message\") VALUES ('Eve', 'Hello World');"

Watch the message-counts change — the count for “Hello World” goes from 2 to 3:

[drasi] Query 'message-counts' (1 change):
  [UPDATE] {"Count":2,"MessageText":"Hello World"} -> {"Count":3,"MessageText":"Hello World"}

Drasi didn’t re-scan the table — it incrementally updated the count from the single change it processed. This is what lets a query stay current over a large dataset without the cost of repeatedly re-reading it, and it’s exactly what backs live dashboards and metrics.

Step 6 of 7: Detect Inactivity

Drasi can query patterns over time, including the absence of change — “something that should have happened but didn’t”. The inactive-senders query returns people who haven’t sent a message in the last 20 seconds. It uses two Drasi custom functions:

  • drasi.changeDateTime(m) — the timestamp when a node was last changed, without a user-managed timestamp column.
  • drasi.trueLater(condition, futureTime) — schedules Drasi to re-evaluate the condition at a future time. Without it, the time-based WHERE clause would only be checked when data changes; with it, Drasi re-evaluates after the 20-second window expires, so idle senders appear even when no new data arrives.

Because everyone who sent a message earlier has now been idle for more than 20 seconds, the query’s result set is already populated with them. To watch a transition, send a message from Alice:

docker exec getting-started-dotnet-postgres psql -U drasi_user -d getting_started \
  -c "INSERT INTO \"Message\" (\"From\", \"Message\") VALUES ('Alice', 'About to go quiet');"

Alice is active again, so she’s immediately removed from inactive-senders:

[drasi] Query 'inactive-senders' (1 change):
  [DELETE] {"LastMessageTimestamp":"...","MessageFrom":"Alice"}

Now wait 20 seconds without sending anything from Alice. With no new data, Drasi re-evaluates the time condition on its own and Alice ages back into the set:

[drasi] Query 'inactive-senders' (1 change):
  [ADD]    {"LastMessageTimestamp":"...","MessageFrom":"Alice"}

No database change triggered that second notification — you expressed the intent in the query, and there’s no polling loop or scheduler in your code.

Step 7 of 7: Join Across Sources

In real systems, related data often lives in different places. Drasi can join across sources using a virtual relationship, so a query traverses data from multiple sources as if it were a single graph — and a change from any source flows through the join in real time.

Alongside the PostgreSQL source, the app runs a second HTTP source called location-tracker. It’s configured with a custom webhook: a POST /locations route that accepts a friendly, flat { name, location, status } payload and shapes it into a UserLocation node. It’s also seeded from a file (locations.jsonl). The messages-with-location query joins each message to its sender’s live location, matching Message.From to UserLocation.name.

First, simulate Brian moving to a new location by POSTing to the webhook:

curl -X POST http://localhost:9100/locations \
  -H "Content-Type: application/json" \
  -d '{"name": "Brian Kernighan", "location": "Conference Room B", "status": "away"}'

The change propagates through the join — Brian’s message now shows the new location, with before and after:

[drasi] Query 'messages-with-location' (1 change):
  [UPDATE] {"Id":2,"Location":"Building A, Floor 3","Message":"Hello World","Sender":"Brian Kernighan","Status":"online"} -> {"Id":2,"Location":"Conference Room B","Message":"Hello World","Sender":"Brian Kernighan","Status":"away"}

Now test the other direction. Send a message from Carol, a sender who has no location yet:

docker exec getting-started-dotnet-postgres psql -U drasi_user -d getting_started \
  -c "INSERT INTO \"Message\" (\"From\", \"Message\") VALUES ('Carol', 'Good morning');"

all-messages reacts, but messages-with-location does not — there’s no matching UserLocation for Carol, so the join produces no row. Now give Carol a location — the same simple payload:

curl -X POST http://localhost:9100/locations \
  -H "Content-Type: application/json" \
  -d '{"name": "Carol", "location": "Home Office", "status": "online"}'

The join resolves the instant data from both sources is available, and Carol’s joined row appears:

[drasi] Query 'messages-with-location' (1 change):
  [ADD]    {"Id":10,"Location":"Home Office","Message":"Good morning","Sender":"Carol","Status":"online"}

No change to PostgreSQL triggered that — it came entirely from the HTTP source. The join is symmetric: a change in either source can complete (or invalidate) a match.

How It Works

Everything you just ran is a single .NET console app under tutorials/getting-started/ — and it’s one file. Program.cs downloads the plugins, adds the sources, registers the five queries, and attaches the reaction. Here’s what each part does.

The Sources

The app connects two sources (Program.cs). PostgreSQL streams Message changes via logical replication (CDC):

await drasi.AddSourceAsync("postgres", "messages", pg, bootstrap: Bootstrap("postgres", pg));

pg is a JsonObject of connection settings (host localhost, port 5832, database getting_started, plus tables, slotName, publicationName, and tableKeys).

The Message table and columns are quoted and PascalCase in the database ("Message", "MessageId", "From"), so the node label and properties Drasi sees match the queries — (m:Message), m.MessageId, m.From — with no change to the query text.

The second source is an HTTP source. Rather than accept the raw event format, it defines a custom webhook route that maps a friendly { name, location, status } payload onto a UserLocation node with Handlebars templates in the plugin config. It also bootstraps its initial data from a JSONL file via the ScriptFile bootstrap provider**:

await drasi.AddSourceAsync(
    "http",
    "location-tracker",
    HttpSourceConfig(httpPort),
    bootstrap: Bootstrap("scriptfile", new JsonObject
    {
        ["filePaths"] = new JsonArray { locationsFile },
    }));

So a caller sends curl -d '{"name":"...","location":"...","status":"..."}' to POST /locations (as you did in Step 7), and the source turns it into a graph change. {{payload.*}} reads fields from the request body; using the sender’s name as the node id means a later POST for the same person updates the same UserLocation.

The Continuous Queries

All five queries are declared explicitly in Program.cs, one AddQuery call each — with the embedded library you describe your topology in code, and every query runs from the moment the app starts. For example, the change-detection query:

drasi.AddQuery("all-messages", """
    MATCH (m:Message)
    RETURN m.MessageId AS MessageId, m.From AS From, m.Message AS Message
    """, ["messages"]);
QueryWhat it does
all-messagesEvery message — change detection
hello-world-sendersOnly Message = 'Hello World' — a WHERE filter
message-countscount(m) grouped by message text — an aggregation
inactive-sendersSenders idle > 20s — time-based, via drasi.trueLater
messages-with-locationMessages joined to live locations — a cross-source join

The join query lists two sources and declares the virtual relationship that connects them via QueryOptions.Joins:

MATCH (m:Message)-[:FROM_USER]->(u:UserLocation)
RETURN m.MessageId AS Id, m.Message AS Message,
       m.From AS Sender, u.location AS Location, u.status AS Status
drasi.AddQuery("messages-with-location", joinCypher,
    ["messages", "location-tracker"], new QueryOptions
{
    Joins =
    [
        new QueryJoin
        {
            Id = "FROM_USER",
            Keys =
            [
                new QueryJoinKey { Label = "Message", Property = "From" },
                new QueryJoinKey { Label = "UserLocation", Property = "name" },
            ],
        },
    ],
});

There’s no foreign key between the two systems — Drasi materializes the relationship from these keys and maintains the join state, propagating changes from either source into the result set.

The C# Reaction

This is where the embedded library differs most from Drasi Server. Instead of configuring a built-in reaction, you attach a callback with AddReaction and subscribe it to whichever queries you care about. It receives every query-result change and does whatever you want — here, a switch on the change type prints it:

drasi.AddReaction("console", [
    "all-messages", "hello-world-senders", "message-counts",
    "inactive-senders", "messages-with-location",
], evt =>
{
    foreach (var diff in evt.Results)
    {
        switch (diff.Type)
        {
            case DiffType.Add:
                Console.WriteLine($"  [ADD]    {diff.Data}");
                break;
            case DiffType.Delete:
                Console.WriteLine($"  [DELETE] {diff.Data}");
                break;
            case DiffType.Update:
            case DiffType.Aggregation:
                Console.WriteLine($"  [UPDATE] {diff.Before} -> {diff.After}");
                break;
        }
    }
});

Each change is a tagged union: ADD and DELETE carry data; UPDATE and aggregation carry before and after. That’s the whole reaction — no plugin to install, no template to write. From here you could just as easily update another database, call an API, or push to a queue.

Putting It All Together

That’s the whole tutorial — and it fits in a single file. Open Program.cs to read it: the generic host, AddDrasi, plugin installs, both sources, the five queries, and the C# reaction that prints every change.

Lines of application logic beyond wiring: just the reaction’s switch. The sources, the five queries, and the join are all declarative — Drasi does the rest.

Clean Up

When you’re finished, stop the app with Ctrl+C in Terminal 1, then remove the database container:

# Stop the container, keep the data
docker compose -f database/docker-compose.yml down

# Stop the container and delete the data volume
docker compose -f database/docker-compose.yml down --volumes

What You Learned

  • Sources connect Drasi to live data — here, PostgreSQL via Change Data Capture and an HTTP source, both embedded directly in a .NET app with Drasi.
  • Continuous Queries detect changes, filter them, aggregate them, reason about time (the absence of change, via drasi.trueLater), and join across sources with virtual relationships — all kept current automatically, with no polling.
  • Reactions turn query changes into action. A C# reaction (AddReactionAsync) handed every change to your own callback — the embedded-library way to react to change in code.

From here, try the Building Comfort and Curbside Pickup tutorials, which build full ASP.NET Core applications with a C# reaction that streams Server-Sent Events to drive a live web UI.