Real-Time Task Board with CQRS
Build a Kanban board with CQRS, domain events, and SignalR live updates.
Introduction
30 Second Summary
Think about how a shared Trello board works. When someone moves a card, every person looking at the board sees it move instantly. That real-time sync is powered by architectural patterns that keep business logic cleanly separated from the notification layer.
In this project, you will build a real-time task board where creating and moving tasks in one browser tab instantly updates every other connected tab. You will wire this up using CQRS, domain events, and SignalR in ASP.NET Core.
What You'll Build
A Kanban-style task board with an ASP.NET Core backend that separates commands from queries using MediatR and pushes live updates to a browser frontend via SignalR WebSockets.
By the end of this project, you'll have:
- A CQRS architecture with separate command and query paths wired through MediatR.
- A SignalR hub that broadcasts task board changes to all connected clients in real time.
- Domain event notification handlers that bridge business logic to the real-time layer without coupling the two.
- A live-updating HTML/JS frontend backed by SQLite persistence via EF Core.
- Secret Mission: Add a drag-and-drop reorder feature with optimistic UI and server reconciliation.
Are there any prerequisites?
This project assumes you are familiar with C#, ASP.NET Core Web APIs, and the basics of Entity Framework Core. You do not need prior experience with SignalR, CQRS, or MediatR.
Create the Project and Install Dependencies
Every real-time application starts with a solid foundation. Before you can build the task board's architecture, you need a project skeleton, the right libraries, and a folder structure that supports the CQRS pattern.
In this step, you will scaffold an ASP.NET Core Empty project, install MediatR and EF Core via NuGet, and create the folders where your domain model, handlers, and hub will live.
In this step, get ready to:
- Create an ASP.NET Core Empty project named TaskBoard.
- Install the MediatR and EF Core NuGet packages.
- Set up the folder structure for CQRS architecture.
Create the ASP.NET Core Empty project
The ASP.NET Core Empty template gives you the thinnest possible starting point. It contains just a Program.cs with a single route, leaving the rest up to you.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the project and move into the new folder by running these commands:
dotnet new web -n TaskBoard
cd TaskBoard
- Confirm the project files exist by running:
ls
You should see Program.cs, TaskBoard.csproj, appsettings.json, appsettings.Development.json, and a Properties folder listed in the output.
Don't see those files?
Make sure you ran the commands from your Desktop folder. If you see an error about the template not being found, confirm your .NET 9 SDK is installed by running dotnet --version.
help me troubleshoot why dotnet new web failed
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio and press Enter to open it.
- Click Open a project or solution.
- Navigate to Desktop > TaskBoard and select the TaskBoard.csproj file.
- Click Open.
Visual Studio loads the project. In the Solution Explorer on the right, double-click Program.cs to open it.
You should see the default scaffold code:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => "Hello World!");
app.Run();
What does this code do?
This is the simplest possible ASP.NET Core app. It creates a web application builder, builds the app, maps a single GET route at / that returns "Hello World!", and starts listening for requests.
You will replace most of this in later steps as you add SignalR, MediatR, and EF Core services.
✔️ Awesome, I've got everything!
Great. Double check you have Program.cs open and the four lines match the scaffold above.
ⓧ I'd like to double check the full code
Here is the complete Program.cs at this point:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => "Hello World!");
app.Run();
Install the required NuGet packages
Your task board needs three libraries. MediatR provides the mediator pattern for dispatching commands and events. EF Core with the SQLite provider handles database persistence.
- In Visual Studio, open the integrated terminal by clicking View in the menu bar, then Terminal.
- Install MediatR by running this command:
dotnet add package MediatR --version 14.2.0
- Install the EF Core SQLite provider by running:
dotnet add package Microsoft.EntityFrameworkCore.Sqlite --version 9.0.17
- Install the EF Core design-time tools by running:
dotnet add package Microsoft.EntityFrameworkCore.Design --version 9.0.17
Each command should output a success message confirming the package was added.
Why three separate packages?
MediatR 14.2.0 gives you the mediator pattern with IRequest, INotification, and their handlers. You will use it to separate commands from queries and to publish domain events.
Microsoft.EntityFrameworkCore.Sqlite provides the database engine. Microsoft.EntityFrameworkCore.Design is needed for EF Core tooling such as migrations (even though this project uses EnsureCreated for simplicity, the design package resolves build-time dependencies).
MediatR license warning
MediatR v14 introduced a licensing model. When you run the app later, you may see a licensing-related warning in the console. This does not affect functionality for development and learning purposes.
Package install failed?
Make sure your terminal is in the TaskBoard project directory (the folder containing TaskBoard.csproj). If you see a version-not-found error, confirm your internet connection is active and NuGet.org is reachable.
help me fix a failed dotnet add package command
Create the project folder structure
A CQRS project benefits from clear folder separation. Each folder will hold a specific part of the architecture.
- Create all five folders by running this command in the terminal:
mkdir Domain Data Features Hubs wwwroot
- Confirm the folders exist by running:
ls
You should see Domain, Data, Features, Hubs, and wwwroot alongside your existing files.
What is each folder for?
- Domain holds your entity models and domain event records.
- Data holds the EF Core DbContext for database access.
- Features holds your CQRS command handlers, query handlers, and event handlers.
- Hubs holds the SignalR hub for real-time communication.
- wwwroot holds the static HTML/JS frontend that connects to the hub.
- Verify the project still compiles by running:
dotnet build
You should see a Build succeeded message in the output.
Build failed?
Check that you did not accidentally modify Program.cs when browsing it earlier. It should still contain the original four-line scaffold. Also confirm the mkdir command ran inside the TaskBoard folder, not one level above it.
help me fix a dotnet build failure in my TaskBoard project
Your project skeleton is ready with all the dependencies it needs. Next up, you will define the domain model and CQRS contracts that form the backbone of the task board's architecture.
Define the Domain Model and CQRS Contracts
Your project skeleton is ready with all packages installed and folders in place. Now it's time to define the core building blocks of your task board: the domain model that represents a task, and the contracts that separate writes from reads.
In CQRS architecture, commands change state and queries read state. By defining these as separate classes upfront, you establish clean boundaries that make your system easier to extend and test.
In this step, get ready to:
- Define the BoardTask entity and domain events.
- Create command and query contracts for the CQRS pattern.
- Set up the EF Core database context for persistence.
Create the domain entity and events
Every task on your board needs a data shape. The BoardTask class represents a single task with its title, description, status, and timestamps.
- In Visual Studio's Solution Explorer, right-click the Domain folder and select Add > New Item.
- Choose Class, name it BoardTask.cs, and click Add.
- Replace the generated content with this code:
namespace TaskBoard.Domain;
public class BoardTask
{
// Unique identifier for the task
public int Id { get; set; }
// Display name shown on the board
public string Title { get; set; } = string.Empty;
// Optional details about the task
public string Description { get; set; } = string.Empty;
// Current column: "Todo", "InProgress", or "Done"
public string Status { get; set; } = "Todo";
// When the task was first created
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
What does this code do?
This class is your domain entity. Each property maps to a column in the database table that EF Core will create.
The Status property defaults to "Todo" so every new task starts in the first column. The CreatedAt timestamp lets you sort tasks by creation order.
You won't see a visible change yet. The next files use this entity.
Now you need domain events. These are lightweight messages that announce "something happened" in your domain. Later, notification handlers will react to these events and push updates to connected clients.
- Right-click the Domain folder again.
- Add a new class named TaskCreatedEvent.cs.
- Replace its content with this code:
using MediatR;
namespace TaskBoard.Domain;
// Published when a new task is added to the board
public record TaskCreatedEvent(BoardTask Task) : INotification;
What is INotification?
INotification is a MediatR marker interface that identifies a class as an event (or "notification"). Any class implementing INotificationHandler<T> will automatically receive the event when it's published.
Using a record instead of a class gives you immutability and value equality for free. Events should never be modified after creation.
- Add another new class in the Domain folder named TaskStatusChangedEvent.cs.
- Replace its content with this code:
using MediatR;
namespace TaskBoard.Domain;
// Published when a task moves between columns (e.g. Todo -> InProgress)
public record TaskStatusChangedEvent(int TaskId, string NewStatus) : INotification;
- Save all three files in the Domain folder.
- Verify the project compiles by running this in the terminal:
dotnet build
You should see Build succeeded in the terminal output.
Seeing build errors?
- Check that the namespace is TaskBoard.Domain in all three files. It must match the folder path.
- Make sure TaskCreatedEvent.cs has using MediatR; at the top. Without it, the compiler can't find INotification.
- Confirm your BoardTask.cs file is inside the Domain folder, not the project root.
Help me debug my domain model build errors.
✔️ Awesome, I've got everything!
Great. Make sure all three domain files are saved before moving on.
ⓧ I'd like to double check the full code
namespace TaskBoard.Domain;
public class BoardTask
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string Status { get; set; } = "Todo";
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
using MediatR;
namespace TaskBoard.Domain;
public record TaskCreatedEvent(BoardTask Task) : INotification;
using MediatR;
namespace TaskBoard.Domain;
public record TaskStatusChangedEvent(int TaskId, string NewStatus) : INotification;
Create the CQRS command and query contracts
With the domain model in place, you need contracts that define what operations are possible. Commands represent actions that change state. Queries represent requests that only read state.
Why separate commands from queries?
Separating writes (commands) from reads (queries) means you can optimize, scale, and test each path independently. A command handler can validate, persist, and publish events without worrying about how data is read back.
- Right-click the Features folder and add a new class named CreateTaskCommand.cs.
- Replace its content with this code:
using MediatR;
using TaskBoard.Domain;
namespace TaskBoard.Features;
// Command to create a new task on the board
public record CreateTaskCommand(string Title, string Description) : IRequest<BoardTask>;
- Add another class in the Features folder named MoveTaskCommand.cs.
- Replace its content with this code:
using MediatR;
namespace TaskBoard.Features;
// Command to move a task to a different status column
public record MoveTaskCommand(int TaskId, string NewStatus) : IRequest<bool>;
- Add one more class in the Features folder named GetAllTasksQuery.cs.
- Replace its content with this code:
using MediatR;
using TaskBoard.Domain;
namespace TaskBoard.Features;
// Query to retrieve all tasks for displaying on the board
public record GetAllTasksQuery() : IRequest<List<BoardTask>>;
What does IRequest do?
IRequest<T> is MediatR's interface for requests that return a value. CreateTaskCommand returns a BoardTask (the newly created task). MoveTaskCommand returns a bool (whether the move succeeded). GetAllTasksQuery returns a List<BoardTask> (every task on the board).
These contracts are just data shapes. The actual logic lives in handlers you'll build in the next step.
- Save all three files in the Features folder.
- Run dotnet build again to confirm everything compiles.
You should see Build succeeded with no errors.
Build failing on the Features files?
- If you see a "type or namespace 'BoardTask' could not be found" error, check that CreateTaskCommand.cs and GetAllTasksQuery.cs both have using TaskBoard.Domain; at the top.
- Make sure the namespace is TaskBoard.Features (not TaskBoard.Domain).
Help me fix my CQRS contract build errors.
✔️ Awesome, I've got everything!
Great. Double check you've saved all three files before continuing.
ⓧ I'd like to double check the full code
using MediatR;
using TaskBoard.Domain;
namespace TaskBoard.Features;
public record CreateTaskCommand(string Title, string Description) : IRequest<BoardTask>;
using MediatR;
namespace TaskBoard.Features;
public record MoveTaskCommand(int TaskId, string NewStatus) : IRequest<bool>;
using MediatR;
using TaskBoard.Domain;
namespace TaskBoard.Features;
public record GetAllTasksQuery() : IRequest<List<BoardTask>>;
Create the EF Core database context
Your domain entity needs a way to reach the database. The DbContext class in EF Core is the bridge between your C# objects and the SQLite database on disk.
- Right-click the Data folder and add a new class named TaskBoardDbContext.cs.
- Replace its content with this code:
using Microsoft.EntityFrameworkCore;
using TaskBoard.Domain;
namespace TaskBoard.Data;
public class TaskBoardDbContext : DbContext
{
// Accept options from DI so the connection string is configured externally
public TaskBoardDbContext(DbContextOptions<TaskBoardDbContext> options) : base(options) { }
// Expose the Tasks table for querying and saving
public DbSet<BoardTask> Tasks => Set<BoardTask>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Configure the BoardTask table schema
modelBuilder.Entity<BoardTask>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Title).IsRequired().HasMaxLength(200);
entity.Property(e => e.Status).IsRequired().HasMaxLength(50);
});
}
}
What does this code do?
- DbSet<BoardTask> Tasks tells EF Core to create a table called Tasks mapped to your BoardTask class.
- OnModelCreating configures constraints. Title is required with a max length of 200. Status is required with a max length of 50.
- The constructor accepts DbContextOptions so the connection string (pointing to a SQLite file) can be injected via dependency injection in Program.cs.
- Save TaskBoardDbContext.cs.
- Run dotnet build one more time to confirm the full project compiles.
You should see Build succeeded with zero errors.
DbContext not compiling?
- Make sure using Microsoft.EntityFrameworkCore; is at the top. This comes from the Microsoft.EntityFrameworkCore.Sqlite package you installed in Step 1.
- If you see a "BoardTask could not be found" error, confirm you have using TaskBoard.Domain; in the file.
- Check that the namespace is TaskBoard.Data and the file is inside the Data folder.
Help me fix my DbContext build errors.
✔️ Awesome, I've got everything!
Great. Make sure the file is saved before moving on.
ⓧ I'd like to double check the full code
using Microsoft.EntityFrameworkCore;
using TaskBoard.Domain;
namespace TaskBoard.Data;
public class TaskBoardDbContext : DbContext
{
public TaskBoardDbContext(DbContextOptions<TaskBoardDbContext> options) : base(options) { }
public DbSet<BoardTask> Tasks => Set<BoardTask>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<BoardTask>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Title).IsRequired().HasMaxLength(200);
entity.Property(e => e.Status).IsRequired().HasMaxLength(50);
});
}
}
Your domain model, events, and CQRS contracts are all in place. Next up, you'll bring these contracts to life by implementing the command and query handlers that persist tasks and publish domain events.
Implement Command and Query Handlers
You have the domain model, events, and CQRS contracts defined. Now it is time to write the actual business logic that runs when a command or query arrives.
Command handlers contain the write-side logic: persisting data and publishing domain events. The query handler contains the read-side logic: fetching data from the database. Once the handlers exist, you will wire everything together in Program.cs so the app can receive HTTP requests and route them through MediatR.
In this step, get ready to:
- Implement command handlers that persist tasks and publish domain events.
- Implement a query handler that retrieves all tasks ordered by creation date.
- Wire up MediatR, EF Core, and Minimal API endpoints in Program.cs.
Implement the command handlers
Command handlers are the write side of CQRS. Each handler receives a command, performs a state change (persisting to the database), and publishes a domain event so other parts of the system can react.
- In Visual Studio, open Features/CreateTaskCommand.cs.
- Replace the entire file contents with the following code:
using MediatR;
using TaskBoard.Data;
using TaskBoard.Domain;
namespace TaskBoard.Features;
// The command record defines WHAT to do (create a task with a title and description)
public record CreateTaskCommand(string Title, string Description) : IRequest<BoardTask>;
// The handler defines HOW to do it (persist to DB, publish event)
public class CreateTaskCommandHandler : IRequestHandler<CreateTaskCommand, BoardTask>
{
private readonly TaskBoardDbContext _db;
private readonly IPublisher _publisher;
public CreateTaskCommandHandler(TaskBoardDbContext db, IPublisher publisher)
{
_db = db;
_publisher = publisher;
}
public async Task<BoardTask> Handle(CreateTaskCommand request, CancellationToken cancellationToken)
{
var task = new BoardTask
{
Title = request.Title,
Description = request.Description,
Status = "Todo",
CreatedAt = DateTime.UtcNow
};
_db.Tasks.Add(task);
await _db.SaveChangesAsync(cancellationToken);
// Publish a domain event so other handlers can react
await _publisher.Publish(new TaskCreatedEvent(task), cancellationToken);
return task;
}
}
What does this code do?
The handler receives the command, creates a new BoardTask entity with a default status of "Todo", and saves it to the database.
After saving, it publishes a TaskCreatedEvent via IPublisher. This is the key CQRS pattern: the command handler does its job (persist) and announces what happened (event) without knowing who listens.
- Save Features/CreateTaskCommand.cs.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before moving on.
ⓧ I'd like to double check the full code
using MediatR;
using TaskBoard.Data;
using TaskBoard.Domain;
namespace TaskBoard.Features;
public record CreateTaskCommand(string Title, string Description) : IRequest<BoardTask>;
public class CreateTaskCommandHandler : IRequestHandler<CreateTaskCommand, BoardTask>
{
private readonly TaskBoardDbContext _db;
private readonly IPublisher _publisher;
public CreateTaskCommandHandler(TaskBoardDbContext db, IPublisher publisher)
{
_db = db;
_publisher = publisher;
}
public async Task<BoardTask> Handle(CreateTaskCommand request, CancellationToken cancellationToken)
{
var task = new BoardTask
{
Title = request.Title,
Description = request.Description,
Status = "Todo",
CreatedAt = DateTime.UtcNow
};
_db.Tasks.Add(task);
await _db.SaveChangesAsync(cancellationToken);
await _publisher.Publish(new TaskCreatedEvent(task), cancellationToken);
return task;
}
}
Now let's implement the handler for moving tasks between columns.
- Open Features/MoveTaskCommand.cs.
- Replace the entire file contents with the following code:
using MediatR;
using TaskBoard.Data;
using TaskBoard.Domain;
namespace TaskBoard.Features;
// The command carries the task ID and the target status
public record MoveTaskCommand(int TaskId, string NewStatus) : IRequest<bool>;
// The handler looks up the task, updates its status, and publishes an event
public class MoveTaskCommandHandler : IRequestHandler<MoveTaskCommand, bool>
{
private readonly TaskBoardDbContext _db;
private readonly IPublisher _publisher;
public MoveTaskCommandHandler(TaskBoardDbContext db, IPublisher publisher)
{
_db = db;
_publisher = publisher;
}
public async Task<bool> Handle(MoveTaskCommand request, CancellationToken cancellationToken)
{
var task = await _db.Tasks.FindAsync(new object[] { request.TaskId }, cancellationToken);
if (task is null) return false;
task.Status = request.NewStatus;
await _db.SaveChangesAsync(cancellationToken);
// Announce the status change so listeners (like SignalR) can react
await _publisher.Publish(new TaskStatusChangedEvent(task.Id, request.NewStatus), cancellationToken);
return true;
}
}
What does this code do?
The handler uses FindAsync to look up the task by ID. If it does not exist, it returns false so the API can return a 404.
When the task is found, it updates the Status property, saves the change, and publishes a TaskStatusChangedEvent. Later, a notification handler will pick up this event and push the update to all connected clients.
- Save Features/MoveTaskCommand.cs.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before moving on.
ⓧ I'd like to double check the full code
using MediatR;
using TaskBoard.Data;
using TaskBoard.Domain;
namespace TaskBoard.Features;
public record MoveTaskCommand(int TaskId, string NewStatus) : IRequest<bool>;
public class MoveTaskCommandHandler : IRequestHandler<MoveTaskCommand, bool>
{
private readonly TaskBoardDbContext _db;
private readonly IPublisher _publisher;
public MoveTaskCommandHandler(TaskBoardDbContext db, IPublisher publisher)
{
_db = db;
_publisher = publisher;
}
public async Task<bool> Handle(MoveTaskCommand request, CancellationToken cancellationToken)
{
var task = await _db.Tasks.FindAsync(new object[] { request.TaskId }, cancellationToken);
if (task is null) return false;
task.Status = request.NewStatus;
await _db.SaveChangesAsync(cancellationToken);
await _publisher.Publish(new TaskStatusChangedEvent(task.Id, request.NewStatus), cancellationToken);
return true;
}
}
Implement the query handler
The query side of CQRS is deliberately simple. It reads data without side effects or event publishing.
- Open Features/GetAllTasksQuery.cs.
- Replace the entire file contents with the following code:
using MediatR;
using Microsoft.EntityFrameworkCore;
using TaskBoard.Data;
using TaskBoard.Domain;
namespace TaskBoard.Features;
public record GetAllTasksQuery() : IRequest<List<BoardTask>>;
// Query handler: read-only, no side effects, no events published
public class GetAllTasksQueryHandler : IRequestHandler<GetAllTasksQuery, List<BoardTask>>
{
private readonly TaskBoardDbContext _db;
public GetAllTasksQueryHandler(TaskBoardDbContext db)
{
_db = db;
}
public async Task<List<BoardTask>> Handle(GetAllTasksQuery request, CancellationToken cancellationToken)
{
return await _db.Tasks
.OrderBy(t => t.CreatedAt)
.ToListAsync(cancellationToken);
}
}
Why no events on the query side?
Commands change state, so they publish events to notify the rest of the system. Queries only read data. Publishing an event on a read would create an infinite loop if any listener triggered another query.
This separation is the core of CQRS: writes go through commands (with events), reads go through queries (no side effects).
- Save Features/GetAllTasksQuery.cs.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before moving on.
ⓧ I'd like to double check the full code
using MediatR;
using Microsoft.EntityFrameworkCore;
using TaskBoard.Data;
using TaskBoard.Domain;
namespace TaskBoard.Features;
public record GetAllTasksQuery() : IRequest<List<BoardTask>>;
public class GetAllTasksQueryHandler : IRequestHandler<GetAllTasksQuery, List<BoardTask>>
{
private readonly TaskBoardDbContext _db;
public GetAllTasksQueryHandler(TaskBoardDbContext db)
{
_db = db;
}
public async Task<List<BoardTask>> Handle(GetAllTasksQuery request, CancellationToken cancellationToken)
{
return await _db.Tasks
.OrderBy(t => t.CreatedAt)
.ToListAsync(cancellationToken);
}
}
Wire up services and API endpoints in Program.cs
The handlers exist but the application does not know about them yet. You need to register MediatR and EF Core in the dependency injection container, create the database on startup, and expose HTTP endpoints that dispatch commands and queries.
- Open Program.cs.
- Replace the entire file contents with the following code:
using Microsoft.EntityFrameworkCore;
using MediatR;
using TaskBoard.Data;
using TaskBoard.Domain;
using TaskBoard.Features;
var builder = WebApplication.CreateBuilder(args);
// Register EF Core with SQLite as the database provider
builder.Services.AddDbContext<TaskBoardDbContext>(options =>
options.UseSqlite("Data Source=taskboard.db"));
// Register MediatR - scans this assembly for all handlers automatically
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssemblyContaining<Program>());
var app = builder.Build();
// Create the database file on first run (no migrations needed for learning)
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<TaskBoardDbContext>();
db.Database.EnsureCreated();
}
// Query endpoint: retrieve all tasks
app.MapGet("/api/tasks", async (ISender sender) =>
{
var tasks = await sender.Send(new GetAllTasksQuery());
return Results.Ok(tasks);
});
// Command endpoint: create a new task
app.MapPost("/api/tasks", async (CreateTaskCommand command, ISender sender) =>
{
var task = await sender.Send(command);
return Results.Created($"/api/tasks/{task.Id}", task);
});
// Command endpoint: move a task to a new status
app.MapPut("/api/tasks/{id}/move", async (int id, MoveTaskRequest request, ISender sender) =>
{
var result = await sender.Send(new MoveTaskCommand(id, request.NewStatus));
return result ? Results.Ok() : Results.NotFound();
});
app.Run();
// Request body shape for the move endpoint
public record MoveTaskRequest(string NewStatus);
What does this code do?
The AddMediatR call scans the assembly for every class that implements IRequestHandler or INotificationHandler and registers them with dependency injection. You never register handlers manually.
The three Minimal API endpoints use ISender to dispatch commands and queries. The endpoints know nothing about databases or events. They just send a message and return the result.
EnsureCreated builds the SQLite database file from your DbContext model the first time the app starts. No migrations are needed for this learning project.
- Save Program.cs.
- Run the application by pressing Ctrl+F5 in Visual Studio (Start Without Debugging).
Seeing a license warning from MediatR?
MediatR 14.2.0 logs a warning about a license key to the console. This is normal for development and does not affect functionality. The library works fully without a license key.
- Open your browser and navigate to http://localhost:<port>/api/tasks (check the console output for the port number). You should see an empty JSON array [].
- Test creating a task by sending a POST request. Open a terminal and run:
curl -X POST http://localhost:5000/api/tasks -H "Content-Type: application/json" -d "{\"title\":\"My First Task\",\"description\":\"Testing the API\"}"
Use your actual port number
Visual Studio may assign a different port. Check the console output when the app starts for a line like Now listening on: http://localhost:XXXX. Replace 5000 in the curl command with your actual port.
- Navigate to http://localhost:<port>/api/tasks again. You should now see a JSON array containing the task you just created, with an id, title, status of "Todo", and a createdAt timestamp.
Getting a 404 or connection refused?
- Make sure the app is still running in Visual Studio. If you closed it, press Ctrl+F5 again.
- Confirm you are using the correct port from the console output.
- Check that you removed the old app.MapGet("/", () => "Hello World!") line. Your new Program.cs should not contain it.
Why isn't my API responding to requests?
✔️ Awesome, I've got everything!
Great. Double check you have saved all files before moving on.
ⓧ I'd like to double check the full code
using Microsoft.EntityFrameworkCore;
using MediatR;
using TaskBoard.Data;
using TaskBoard.Domain;
using TaskBoard.Features;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<TaskBoardDbContext>(options =>
options.UseSqlite("Data Source=taskboard.db"));
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssemblyContaining<Program>());
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<TaskBoardDbContext>();
db.Database.EnsureCreated();
}
app.MapGet("/api/tasks", async (ISender sender) =>
{
var tasks = await sender.Send(new GetAllTasksQuery());
return Results.Ok(tasks);
});
app.MapPost("/api/tasks", async (CreateTaskCommand command, ISender sender) =>
{
var task = await sender.Send(command);
return Results.Created($"/api/tasks/{task.Id}", task);
});
app.MapPut("/api/tasks/{id}/move", async (int id, MoveTaskRequest request, ISender sender) =>
{
var result = await sender.Send(new MoveTaskCommand(id, request.NewStatus));
return result ? Results.Ok() : Results.NotFound();
});
app.Run();
public record MoveTaskRequest(string NewStatus);
Your CQRS handlers are working. Commands persist state and publish domain events. The query reads without side effects. Next up, you will create the SignalR hub and wire up notification handlers so those domain events automatically push live updates to connected clients.
Create the SignalR Hub and Domain Event Handlers
Your command and query handlers are working, and domain events are being published. The missing piece is something that listens for those events and pushes updates to connected clients in real time.
In this step, you will create a SignalR hub that browsers connect to over WebSockets. Then you will build notification handlers that catch domain events and broadcast them through the hub to every connected client.
In this step, get ready to:
- Create a SignalR hub and register it in the app pipeline.
- Build notification handlers that bridge domain events to SignalR broadcasts.
- Verify the full event flow from command handler through to hub broadcast.
Create the SignalR hub
A SignalR hub is the server-side endpoint that clients connect to. In this project, the hub itself has no custom methods because clients only listen for server-pushed messages.
- Open Hubs/BoardHub.cs (the empty file in the Hubs folder) and paste the following code:
using Microsoft.AspNetCore.SignalR;
namespace TaskBoard.Hubs;
// The hub that browser clients connect to via WebSocket
public class BoardHub : Hub
{
// Called automatically when a client connects
public override async Task OnConnectedAsync()
{
await base.OnConnectedAsync();
}
}
What does this code do?
BoardHub inherits from Hub, which gives it all the WebSocket connection management for free. The OnConnectedAsync override runs whenever a new client connects.
You are not adding any methods that clients call. Instead, you will push messages from the server side using IHubContext<BoardHub> in your notification handlers.
- Save Hubs/BoardHub.cs.
Now register SignalR in your application and map the hub endpoint.
- Switch back to Program.cs.
- Find this line:
builder.Services.AddDbContext<TaskBoardDbContext>(options =>
- Add the following line directly above it:
builder.Services.AddSignalR();
- Now scroll down to find this line:
app.Run();
- Add the following line directly above app.Run();:
app.MapHub<BoardHub>("/hubs/board");
What do these lines do?
AddSignalR() registers the SignalR services (connection management, serialization, hub activation) into the dependency injection container.
MapHub<BoardHub>("/hubs/board") exposes the hub at the /hubs/board URL path. Clients will connect to this endpoint to start receiving real-time messages.
- Save Program.cs.
- Confirm the project builds by running:
dotnet build
You should see a successful build with no errors.
Seeing a build error?
Check that builder.Services.AddSignalR(); is placed before var app = builder.Build();. Service registrations must come before building the app.
Make sure app.MapHub<BoardHub>("/hubs/board"); is placed after var app = builder.Build(); but before app.Run();.
Help me fix my SignalR build error
Create the domain event notification handlers
Your command handlers already publish domain events via MediatR. Now you need handlers that subscribe to those events and push messages through the SignalR hub to all connected browsers.
Why this decoupling matters
The command handler that creates a task knows nothing about SignalR. It just publishes a TaskCreatedEvent. A separate notification handler picks up that event and broadcasts it. This means you can add more subscribers (logging, analytics, email) without touching the command handler.
- Create a new file Features/TaskCreatedEventHandler.cs and paste the following code:
using MediatR;
using Microsoft.AspNetCore.SignalR;
using TaskBoard.Domain;
using TaskBoard.Hubs;
namespace TaskBoard.Features;
// Listens for TaskCreatedEvent and broadcasts to all SignalR clients
public class TaskCreatedEventHandler : INotificationHandler<TaskCreatedEvent>
{
private readonly IHubContext<BoardHub> _hubContext;
public TaskCreatedEventHandler(IHubContext<BoardHub> hubContext)
{
_hubContext = hubContext;
}
public async Task Handle(TaskCreatedEvent notification, CancellationToken cancellationToken)
{
// Send the full task payload to all connected clients
await _hubContext.Clients.All.SendAsync("TaskCreated", new
{
notification.Task.Id,
notification.Task.Title,
notification.Task.Description,
notification.Task.Status,
notification.Task.CreatedAt
}, cancellationToken);
}
}
What does this code do?
INotificationHandler<TaskCreatedEvent> tells MediatR to invoke this handler whenever a TaskCreatedEvent is published. MediatR discovers it automatically through assembly scanning.
IHubContext<BoardHub> is injected by the DI container. It lets you send messages through the hub from outside the hub class. Clients.All.SendAsync broadcasts the task data to every connected client.
- Save Features/TaskCreatedEventHandler.cs.
Now create the second handler for task status changes.
- Create a new file Features/TaskStatusChangedEventHandler.cs and paste the following code:
using MediatR;
using Microsoft.AspNetCore.SignalR;
using TaskBoard.Domain;
using TaskBoard.Hubs;
namespace TaskBoard.Features;
// Listens for TaskStatusChangedEvent and broadcasts the move to all clients
public class TaskStatusChangedEventHandler : INotificationHandler<TaskStatusChangedEvent>
{
private readonly IHubContext<BoardHub> _hubContext;
public TaskStatusChangedEventHandler(IHubContext<BoardHub> hubContext)
{
_hubContext = hubContext;
}
public async Task Handle(TaskStatusChangedEvent notification, CancellationToken cancellationToken)
{
// Send only the task ID and new status to all clients
await _hubContext.Clients.All.SendAsync("TaskMoved", new
{
notification.TaskId,
notification.NewStatus
}, cancellationToken);
}
}
What does this code do?
This handler follows the same pattern as the create handler. When a task moves between columns, the MoveTaskCommandHandler publishes a TaskStatusChangedEvent. This handler catches it and sends a TaskMoved message containing only the task ID and new status.
- Save Features/TaskStatusChangedEventHandler.cs.
- Confirm everything compiles by running:
dotnet build
You should see a successful build with no errors.
Build fails with a missing namespace?
Make sure both event handler files include using Microsoft.AspNetCore.SignalR; and using TaskBoard.Hubs; at the top. Without these, the compiler cannot resolve IHubContext<BoardHub>.
Help me fix my notification handler build error
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files: Hubs/BoardHub.cs, Features/TaskCreatedEventHandler.cs, and Features/TaskStatusChangedEventHandler.cs.
ⓧ I'd like to double check the full code
using Microsoft.AspNetCore.SignalR;
namespace TaskBoard.Hubs;
public class BoardHub : Hub
{
public override async Task OnConnectedAsync()
{
await base.OnConnectedAsync();
}
}
using MediatR;
using Microsoft.AspNetCore.SignalR;
using TaskBoard.Domain;
using TaskBoard.Hubs;
namespace TaskBoard.Features;
public class TaskCreatedEventHandler : INotificationHandler<TaskCreatedEvent>
{
private readonly IHubContext<BoardHub> _hubContext;
public TaskCreatedEventHandler(IHubContext<BoardHub> hubContext)
{
_hubContext = hubContext;
}
public async Task Handle(TaskCreatedEvent notification, CancellationToken cancellationToken)
{
await _hubContext.Clients.All.SendAsync("TaskCreated", new
{
notification.Task.Id,
notification.Task.Title,
notification.Task.Description,
notification.Task.Status,
notification.Task.CreatedAt
}, cancellationToken);
}
}
using MediatR;
using Microsoft.AspNetCore.SignalR;
using TaskBoard.Domain;
using TaskBoard.Hubs;
namespace TaskBoard.Features;
public class TaskStatusChangedEventHandler : INotificationHandler<TaskStatusChangedEvent>
{
private readonly IHubContext<BoardHub> _hubContext;
public TaskStatusChangedEventHandler(IHubContext<BoardHub> hubContext)
{
_hubContext = hubContext;
}
public async Task Handle(TaskStatusChangedEvent notification, CancellationToken cancellationToken)
{
await _hubContext.Clients.All.SendAsync("TaskMoved", new
{
notification.TaskId,
notification.NewStatus
}, cancellationToken);
}
}
using Microsoft.EntityFrameworkCore;
using MediatR;
using TaskBoard.Data;
using TaskBoard.Domain;
using TaskBoard.Features;
using TaskBoard.Hubs;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSignalR();
builder.Services.AddDbContext<TaskBoardDbContext>(options =>
options.UseSqlite("Data Source=taskboard.db"));
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssemblyContaining<Program>());
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<TaskBoardDbContext>();
db.Database.EnsureCreated();
}
app.MapGet("/api/tasks", async (ISender sender) =>
{
var tasks = await sender.Send(new GetAllTasksQuery());
return Results.Ok(tasks);
});
app.MapPost("/api/tasks", async (CreateTaskCommand command, ISender sender) =>
{
var task = await sender.Send(command);
return Results.Created($"/api/tasks/{task.Id}", task);
});
app.MapPut("/api/tasks/{id}/move", async (int id, MoveTaskRequest request, ISender sender) =>
{
var result = await sender.Send(new MoveTaskCommand(id, request.NewStatus));
return result ? Results.Ok() : Results.NotFound();
});
app.MapHub<BoardHub>("/hubs/board");
app.Run();
public record MoveTaskRequest(string NewStatus);
Verify the real-time event flow
Time to confirm the full pipeline works. When you create a task, the domain event should fire and the notification handler should execute.
- Start the application by running:
dotnet run
- Open a second terminal window and create a task by running:
curl -X POST http://localhost:5000/api/tasks -H "Content-Type: application/json" -d "{\"title\": \"Test Task\", \"description\": \"Verifying event flow\"}"
What should I see?
The POST response should return the created task as JSON with an id, title, status, and createdAt fields. Behind the scenes, the CreateTaskCommandHandler published a TaskCreatedEvent, and your TaskCreatedEventHandler called SendAsync("TaskCreated", ...) on the hub. No clients are connected yet, so the broadcast goes to an empty audience. That changes in the next step when you build the frontend.
- Confirm the hub endpoint is reachable by navigating to http://localhost:5000/hubs/board/negotiate?negotiateVersion=1 in your browser or via a POST request.
You should see a JSON response containing connection details. This confirms SignalR is correctly mapped and accepting client negotiation requests.
- Verify your tasks were saved by navigating to http://localhost:5000/api/tasks in your browser. You should see your test task in the JSON response.
- Stop the application with Ctrl+C in the terminal running dotnet run.
Connection refused or port mismatch?
Your app may start on a different port (like 5041 or 5274). Check the terminal output after dotnet run for lines showing which URL the server is listening on, then use that URL instead.
Help me troubleshoot my SignalR hub connection
Your backend architecture is now fully wired. Commands create and move tasks, domain events fire automatically, and notification handlers broadcast those events through SignalR. Next up, you will build the frontend that connects to the hub and brings the board to life in real time.
Build the Frontend and Test Real-Time Updates
Your backend is ready to broadcast real-time events, but there's nothing for users to actually see or interact with.
In this step, you'll build a Kanban-style HTML frontend that connects to your SignalR hub via WebSockets. When any browser tab creates or moves a task, every other connected tab updates instantly without a page refresh.
In this step, get ready to:
- Create a three-column Kanban board in HTML with styling.
- Write a JavaScript SignalR client that syncs the board in real time.
- Enable static file serving and verify live updates across two browser tabs.
Create the HTML layout and board structure
The frontend is a single HTML file with embedded CSS that renders three columns. It also loads the SignalR JavaScript client from a CDN so the browser can open a WebSocket connection to your hub.
- Create a new file called index.html inside the wwwroot folder of your TaskBoard project.
- Paste the following HTML and CSS into the file:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Real-Time Task Board</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #1a1a2e; color: #eee; padding: 20px; }
h1 { text-align: center; margin-bottom: 20px; color: #e94560; }
.board { display: flex; gap: 16px; justify-content: center; }
.column { background: #16213e; border-radius: 8px; padding: 16px; width: 300px; min-height: 400px; }
.column h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 12px; color: #a8a8b3; }
.task-card { background: #0f3460; border-radius: 6px; padding: 12px; margin-bottom: 8px; cursor: pointer; transition: transform 0.1s; }
.task-card:hover { transform: translateY(-2px); }
.task-card h3 { font-size: 14px; margin-bottom: 4px; }
.task-card p { font-size: 12px; color: #a8a8b3; }
.task-card .actions { margin-top: 8px; display: flex; gap: 4px; }
.task-card .actions button { font-size: 11px; padding: 4px 8px; border: none; border-radius: 4px; cursor: pointer; background: #e94560; color: white; }
.task-card .actions button:hover { background: #c23152; }
.add-form { max-width: 600px; margin: 0 auto 20px; display: flex; gap: 8px; }
.add-form input { flex: 1; padding: 10px; border-radius: 6px; border: 1px solid #333; background: #16213e; color: #eee; }
.add-form button { padding: 10px 20px; border-radius: 6px; border: none; background: #e94560; color: white; cursor: pointer; font-weight: bold; }
.add-form button:hover { background: #c23152; }
.status-bar { text-align: center; margin-bottom: 16px; font-size: 12px; color: #a8a8b3; }
.status-bar .connected { color: #4caf50; }
.status-bar .disconnected { color: #e94560; }
</style>
</head>
<body>
<h1>Real-Time Task Board</h1>
<div class="status-bar">Connection: <span id="status" class="disconnected">Connecting...</span></div>
<div class="add-form">
<input type="text" id="title" placeholder="Task title" />
<input type="text" id="description" placeholder="Description (optional)" />
<button onclick="createTask()">Add Task</button>
</div>
<div class="board">
<div class="column" id="col-Todo">
<h2>Todo</h2>
<div class="task-list"></div>
</div>
<div class="column" id="col-InProgress">
<h2>In Progress</h2>
<div class="task-list"></div>
</div>
<div class="column" id="col-Done">
<h2>Done</h2>
<div class="task-list"></div>
</div>
</div>
<script src="https://unpkg.com/@microsoft/signalr@8.0.7/dist/browser/signalr.min.js"></script>
<script>
// SignalR client code goes in this script block
</script>
</body>
</html>
What does this HTML do?
- The status-bar div shows the WebSocket connection state so you can see when SignalR connects or disconnects.
- The add-form section provides inputs for creating new tasks. The button calls createTask() which you'll write in the next substep.
- The three .column divs represent the Kanban columns (Todo, In Progress, Done). Each has a .task-list container where task cards will be injected dynamically.
- The CDN <script> tag loads the SignalR JavaScript client library from unpkg.
- Save wwwroot/index.html.
You won't see a change yet because the app doesn't serve static files. The next substep adds the JavaScript, and substep 3 wires everything together.
Add the SignalR JavaScript client
The JavaScript code establishes a WebSocket connection to your hub, registers event listeners for TaskCreated and TaskMoved messages, and provides functions to create tasks, move them between columns, and update the DOM.
- In wwwroot/index.html, find the placeholder comment inside the second <script> tag.
- Replace the entire <script>// SignalR client code goes in this script block</script> block with the following:
<script>
// Build the SignalR connection to the board hub
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/board")
.withAutomaticReconnect()
.build();
// Listen for real-time events from the server
connection.on("TaskCreated", (task) => { addTaskToColumn(task); });
connection.on("TaskMoved", (data) => { moveTaskInDom(data.taskId, data.newStatus); });
// Update status indicator on reconnect and disconnect
connection.onreconnected(() => {
document.getElementById("status").textContent = "Connected";
document.getElementById("status").className = "connected";
});
connection.onclose(() => {
document.getElementById("status").textContent = "Disconnected";
document.getElementById("status").className = "disconnected";
});
// Start the connection and load existing tasks
async function start() {
try {
await connection.start();
document.getElementById("status").textContent = "Connected";
document.getElementById("status").className = "connected";
await loadTasks();
} catch (err) {
document.getElementById("status").textContent = "Failed to connect";
setTimeout(start, 5000);
}
}
// Fetch all existing tasks from the API on page load
async function loadTasks() {
const response = await fetch("/api/tasks");
const tasks = await response.json();
tasks.forEach(task => addTaskToColumn(task));
}
// Send a new task to the API
async function createTask() {
const title = document.getElementById("title").value.trim();
const description = document.getElementById("description").value.trim();
if (!title) return;
await fetch("/api/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, description })
});
document.getElementById("title").value = "";
document.getElementById("description").value = "";
}
// Send a move request to the API
async function moveTask(taskId, newStatus) {
await fetch(`/api/tasks/${taskId}/move`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ newStatus })
});
}
// Add a task card to the correct column in the DOM
function addTaskToColumn(task) {
const existing = document.getElementById(`task-${task.id}`);
if (existing) existing.remove();
const column = document.querySelector(`#col-${task.status} .task-list`);
if (!column) return;
const card = document.createElement("div");
card.className = "task-card";
card.id = `task-${task.id}`;
card.dataset.status = task.status;
let buttons = "";
if (task.status === "Todo") buttons = `<button onclick="moveTask(${task.id}, 'InProgress')">Start</button>`;
if (task.status === "InProgress") buttons = `<button onclick="moveTask(${task.id}, 'Done')">Done</button><button onclick="moveTask(${task.id}, 'Todo')">Back</button>`;
if (task.status === "Done") buttons = `<button onclick="moveTask(${task.id}, 'InProgress')">Reopen</button>`;
card.innerHTML = `<h3>${task.title}</h3><p>${task.description || ""}</p><div class="actions">${buttons}</div>`;
column.appendChild(card);
}
// Move a task card to a different column in the DOM
function moveTaskInDom(taskId, newStatus) {
const card = document.getElementById(`task-${taskId}`);
if (!card) return;
card.dataset.status = newStatus;
const column = document.querySelector(`#col-${newStatus} .task-list`);
if (!column) return;
let buttons = "";
if (newStatus === "Todo") buttons = `<button onclick="moveTask(${taskId}, 'InProgress')">Start</button>`;
if (newStatus === "InProgress") buttons = `<button onclick="moveTask(${taskId}, 'Done')">Done</button><button onclick="moveTask(${taskId}, 'Todo')">Back</button>`;
if (newStatus === "Done") buttons = `<button onclick="moveTask(${taskId}, 'InProgress')">Reopen</button>`;
const actionsDiv = card.querySelector(".actions");
if (actionsDiv) actionsDiv.innerHTML = buttons;
column.appendChild(card);
}
// Kick off the connection
start();
</script>
What does this JavaScript do?
- HubConnectionBuilder creates a WebSocket connection to /hubs/board with automatic reconnection if the connection drops.
- connection.on("TaskCreated", ...) and connection.on("TaskMoved", ...) register listeners for the exact event names your notification handlers broadcast via SendAsync.
- loadTasks() fetches all existing tasks from GET /api/tasks so the board displays persisted data on page load.
- addTaskToColumn() dynamically creates a task card with status-appropriate action buttons (Start, Done, Back, Reopen).
- moveTaskInDom() moves an existing card to a different column and updates its buttons without recreating it from scratch.
- Save wwwroot/index.html.
The file is complete. You still can't see it in a browser because the server isn't serving static files yet.
Seeing a syntax error in the file?
Make sure the closing </script> tag is present and that you removed the old placeholder comment line completely. Check that no duplicate <script> opening tags remain.
Help me find the syntax error in my code.
Enable static files and test real-time sync
ASP.NET Core doesn't serve files from wwwroot by default. You need to register the static files middleware and a fallback route so navigating to the root URL loads your index.html.
- In Program.cs, find the closing brace of the using block that calls db.Database.EnsureCreated().
- Find this line immediately after that block:
app.MapGet("/api/tasks", async (ISender sender) =>
- Add the following line directly above it (between the using block and the first MapGet):
app.UseStaticFiles();
- Now scroll down to the bottom of Program.cs. Find this line:
app.MapHub<BoardHub>("/hubs/board");
- Add the following line directly below it:
app.MapFallbackToFile("index.html");
What do these two lines do?
UseStaticFiles() enables the middleware that serves files from wwwroot (your CSS, JS, and HTML). Without it, requests for static assets return 404.
MapFallbackToFile("index.html") is a catch-all route. If no API endpoint or static file matches the request, it serves index.html. This means navigating to the root URL loads your task board.
- Save Program.cs.
- Stop any running instance of the app, then start it again by running:
dotnet run
- Open your browser and navigate to the URL shown in the terminal output (typically http://localhost:5000 or the https port listed in launchSettings.json).
- You should see the dark-themed task board with three empty columns (Todo, In Progress, Done) and the connection status showing "Connected" in green.
- Open a second browser tab at the same URL.
- In one tab, type a task title (e.g. Design homepage) and click Add Task.
- Watch the other tab. The new task card should appear in the Todo column within a second, without any page refresh.
- Click the Start button on the task in one tab. The card moves to In Progress in both tabs simultaneously.
Board not loading or showing 'Disconnected'?
- Make sure app.UseStaticFiles() is placed before the MapGet calls, not after them. Middleware order matters in ASP.NET Core.
- Check that index.html is inside the wwwroot folder (not the project root). The static files middleware only serves from wwwroot.
- If the connection status says "Failed to connect", check your browser console for errors. The SignalR CDN script may be blocked by a firewall or ad blocker.
Help me debug my SignalR connection.
✔️ Awesome, I've got everything!
Great. Double check you've saved both files before moving on.
ⓧ I'd like to double check the full code
Here is the complete wwwroot/index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Real-Time Task Board</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #1a1a2e; color: #eee; padding: 20px; }
h1 { text-align: center; margin-bottom: 20px; color: #e94560; }
.board { display: flex; gap: 16px; justify-content: center; }
.column { background: #16213e; border-radius: 8px; padding: 16px; width: 300px; min-height: 400px; }
.column h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 12px; color: #a8a8b3; }
.task-card { background: #0f3460; border-radius: 6px; padding: 12px; margin-bottom: 8px; cursor: pointer; transition: transform 0.1s; }
.task-card:hover { transform: translateY(-2px); }
.task-card h3 { font-size: 14px; margin-bottom: 4px; }
.task-card p { font-size: 12px; color: #a8a8b3; }
.task-card .actions { margin-top: 8px; display: flex; gap: 4px; }
.task-card .actions button { font-size: 11px; padding: 4px 8px; border: none; border-radius: 4px; cursor: pointer; background: #e94560; color: white; }
.task-card .actions button:hover { background: #c23152; }
.add-form { max-width: 600px; margin: 0 auto 20px; display: flex; gap: 8px; }
.add-form input { flex: 1; padding: 10px; border-radius: 6px; border: 1px solid #333; background: #16213e; color: #eee; }
.add-form button { padding: 10px 20px; border-radius: 6px; border: none; background: #e94560; color: white; cursor: pointer; font-weight: bold; }
.add-form button:hover { background: #c23152; }
.status-bar { text-align: center; margin-bottom: 16px; font-size: 12px; color: #a8a8b3; }
.status-bar .connected { color: #4caf50; }
.status-bar .disconnected { color: #e94560; }
</style>
</head>
<body>
<h1>Real-Time Task Board</h1>
<div class="status-bar">Connection: <span id="status" class="disconnected">Connecting...</span></div>
<div class="add-form">
<input type="text" id="title" placeholder="Task title" />
<input type="text" id="description" placeholder="Description (optional)" />
<button onclick="createTask()">Add Task</button>
</div>
<div class="board">
<div class="column" id="col-Todo">
<h2>Todo</h2>
<div class="task-list"></div>
</div>
<div class="column" id="col-InProgress">
<h2>In Progress</h2>
<div class="task-list"></div>
</div>
<div class="column" id="col-Done">
<h2>Done</h2>
<div class="task-list"></div>
</div>
</div>
<script src="https://unpkg.com/@microsoft/signalr@8.0.7/dist/browser/signalr.min.js"></script>
<script>
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/board")
.withAutomaticReconnect()
.build();
connection.on("TaskCreated", (task) => { addTaskToColumn(task); });
connection.on("TaskMoved", (data) => { moveTaskInDom(data.taskId, data.newStatus); });
connection.onreconnected(() => {
document.getElementById("status").textContent = "Connected";
document.getElementById("status").className = "connected";
});
connection.onclose(() => {
document.getElementById("status").textContent = "Disconnected";
document.getElementById("status").className = "disconnected";
});
async function start() {
try {
await connection.start();
document.getElementById("status").textContent = "Connected";
document.getElementById("status").className = "connected";
await loadTasks();
} catch (err) {
document.getElementById("status").textContent = "Failed to connect";
setTimeout(start, 5000);
}
}
async function loadTasks() {
const response = await fetch("/api/tasks");
const tasks = await response.json();
tasks.forEach(task => addTaskToColumn(task));
}
async function createTask() {
const title = document.getElementById("title").value.trim();
const description = document.getElementById("description").value.trim();
if (!title) return;
await fetch("/api/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, description })
});
document.getElementById("title").value = "";
document.getElementById("description").value = "";
}
async function moveTask(taskId, newStatus) {
await fetch(`/api/tasks/${taskId}/move`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ newStatus })
});
}
function addTaskToColumn(task) {
const existing = document.getElementById(`task-${task.id}`);
if (existing) existing.remove();
const column = document.querySelector(`#col-${task.status} .task-list`);
if (!column) return;
const card = document.createElement("div");
card.className = "task-card";
card.id = `task-${task.id}`;
card.dataset.status = task.status;
let buttons = "";
if (task.status === "Todo") buttons = `<button onclick="moveTask(${task.id}, 'InProgress')">Start</button>`;
if (task.status === "InProgress") buttons = `<button onclick="moveTask(${task.id}, 'Done')">Done</button><button onclick="moveTask(${task.id}, 'Todo')">Back</button>`;
if (task.status === "Done") buttons = `<button onclick="moveTask(${task.id}, 'InProgress')">Reopen</button>`;
card.innerHTML = `<h3>${task.title}</h3><p>${task.description || ""}</p><div class="actions">${buttons}</div>`;
column.appendChild(card);
}
function moveTaskInDom(taskId, newStatus) {
const card = document.getElementById(`task-${taskId}`);
if (!card) return;
card.dataset.status = newStatus;
const column = document.querySelector(`#col-${newStatus} .task-list`);
if (!column) return;
let buttons = "";
if (newStatus === "Todo") buttons = `<button onclick="moveTask(${taskId}, 'InProgress')">Start</button>`;
if (newStatus === "InProgress") buttons = `<button onclick="moveTask(${taskId}, 'Done')">Done</button><button onclick="moveTask(${taskId}, 'Todo')">Back</button>`;
if (newStatus === "Done") buttons = `<button onclick="moveTask(${taskId}, 'InProgress')">Reopen</button>`;
const actionsDiv = card.querySelector(".actions");
if (actionsDiv) actionsDiv.innerHTML = buttons;
column.appendChild(card);
}
start();
</script>
</body>
</html>
Here is the complete Program.cs:
using Microsoft.EntityFrameworkCore;
using MediatR;
using TaskBoard.Data;
using TaskBoard.Domain;
using TaskBoard.Features;
using TaskBoard.Hubs;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSignalR();
builder.Services.AddDbContext<TaskBoardDbContext>(options =>
options.UseSqlite("Data Source=taskboard.db"));
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssemblyContaining<Program>());
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<TaskBoardDbContext>();
db.Database.EnsureCreated();
}
app.UseStaticFiles();
app.MapGet("/api/tasks", async (ISender sender) =>
{
var tasks = await sender.Send(new GetAllTasksQuery());
return Results.Ok(tasks);
});
app.MapPost("/api/tasks", async (CreateTaskCommand command, ISender sender) =>
{
var task = await sender.Send(command);
return Results.Created($"/api/tasks/{task.Id}", task);
});
app.MapPut("/api/tasks/{id}/move", async (int id, MoveTaskRequest request, ISender sender) =>
{
var result = await sender.Send(new MoveTaskCommand(id, request.NewStatus));
return result ? Results.Ok() : Results.NotFound();
});
app.MapHub<BoardHub>("/hubs/board");
app.MapFallbackToFile("index.html");
app.Run();
public record MoveTaskRequest(string NewStatus);
Secret mission
Add Priority Reordering with Optimistic UI
Your task board moves cards between columns, but what about reordering within a column? Production apps like Trello and Linear let users drag tasks to prioritize them. In this secret mission, you'll implement drag-and-drop reordering with optimistic UI updates and server reconciliation.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with a SQLite database, so there are no ongoing cloud costs.
Resources you used:
- The TaskBoard project folder on your Desktop (contains all source code, NuGet packages, and the SQLite database).
- The taskboard.db SQLite database file (inside the project folder).
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with the task board.
- Your TaskBoard project folder stays on your Desktop, ready to run anytime with dotnet run.
- The SQLite database retains all tasks you created during the project.
- The MediatR and EF Core NuGet packages remain cached globally on your machine. They take minimal disk space.
Pause - I'll come back to this later
Stop the running application to free up the port and memory. Your files stay intact for next time.
- In your terminal, press Ctrl+C to stop the application if it is still running.
- Close any browser tabs open to localhost.
- When you come back, navigate to the TaskBoard folder and run dotnet run to start the app again.
Delete - I don't want to use this again
Remove all project files and the local database. This cannot be undone.
- In your terminal, press Ctrl+C to stop the application if it is still running.
- Delete the entire project folder by running the command for your operating system below.
macOS
cd ~/Desktop
rm -rf TaskBoard
Windows
cd ~\Desktop
rmdir /s /q TaskBoard
- Confirm the folder is gone by running:
ls ~/Desktop
You should no longer see TaskBoard in the output.
Nice Work!
Nice Work!
Nice work! You just built a production-style real-time task board powered by CQRS, domain events, and SignalR.
You've learned how to:
- Implement CQRS architecture separating commands (writes) from queries (reads) using MediatR to keep business logic cleanly organized.
- Create domain events that decouple business logic from the SignalR real-time notification layer so command handlers never know about WebSocket delivery.
- Build a live-updating frontend that syncs task board changes across multiple browser clients via SignalR WebSockets without page refreshes.
- Secret Mission: Added optimistic UI with drag-and-drop reordering and server reconciliation for a production-grade collaborative experience.
Ready to quiz yourself?