Library / SDK
realworld-apps/aspnetcore-realworld-example-app avatar
realworld-apps/aspnetcore-realworld-example-app

Inside the ASP.NET Core RealWorld Conduit backend: feature slices, a hard JWT key gate and a managed test server that wipes its own database

ASP.NET Core backend implementation for RealWorld

2,094 stars644 forksC#MIT

At a glance

What is it?
realworld-apps/aspnetcore-realworld-example-app is a C# reference backend for the RealWorld API spec, built as feature-oriented vertical slices with source-generated Mediator, Mapperly and FluentValidation, and validated against the official Hurl and Bruno suites. Its strict token and signing-key rules make it a stricter starting point than a typical tutorial API.
Who is it for?
Adopt this backend when your team wants a spec-checked ASP.NET Core starting point and will accept the vertical-slice layout, the EF Core provider switch and the token rules that log out every existing session on upgrade.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 2 days ago.
What is it written in?
Mainly C#, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 21, 2026, and from our analysis. They are not legal advice.

Editorial analysis

Vertical slices dispatched through a source-generated mediator

The codebase is organised by feature rather than by layer: a request travels into a feature folder, gets handled, and returns a response, instead of passing through controller, service and repository assemblies. The dispatch path runs on CQRS with the source-generated Mediator, so handler registration and the request-to-response mapping are produced by the compiler rather than resolved from a container at runtime. That choice matters when you read the repository layout: there is no handwritten composition root per feature, because registration is generated.

The solution file at the top level is `Conduit.slnx`, not the older `.sln` format, and shared build settings live in `Directory.Build.props` with package versions centralised in `Directory.Packages.props`. Persistence is Entity Framework Core with SQLite for local and demo use, and the project also carries SQL Server support, so the provider is a decision you make rather than a fork you maintain.

The whole slice approach is borrowed on purpose. The project states that its basic architecture is based on the ContosoUniversityCore reference architecture, which makes it a study of a specific style rather than a from-scratch design.

Object mapping and request validation that fail at compile time or at the boundary

Mapperly handles object mapping, which means the mapping between entities, commands and responses is generated during compilation instead of being written by hand or reflected at runtime. Combined with the source-generated mediator, the practical effect is that most wiring mistakes in a new feature surface while building.

FluentValidation takes the other half of the boundary. Validation rules sit next to the request they describe, and the feature slice is the natural place to attach them, which keeps a create-article rule and a create-article handler in one folder. CSharpier handles formatting and `.editorconfig` enforces usage patterns, so the build pipeline treats style as a gate rather than a suggestion.

Authentication is ASP.NET Core JWT Bearer Authentication. Swagger is not hand-wired: it comes from Swashbuckle.AspNetCore, which means the published API surface is generated from the code and from the spec's route layout. All endpoints sit under `/api` because the RealWorld spec requires it, and the prefix is configurable through the `ApiPrefix` configuration key in appsettings or an environment variable.

Local Swagger answers on port 5000 while Docker publishes 8080

The README points you at `http://localhost:5000/swagger` after `make run-local`, and the Makefile makes that true by setting `ASPNETCORE_URLS` itself:

sh
ASPNETCORE_URLS=$(API_URL) dotnet run --project $(PROJECT)

The Docker path is a different port. The compose file publishes `8080:8080` on the `conduit` service, and the Dockerfile exposes 8080 before its entrypoint starts `Conduit.dll`. So the two documented ways to start the API do not serve on the same port, and a spec test or a frontend configured for 5000 will not reach the container without rewriting the base URL. `make build` and `make run` simply wrap `docker compose build` and `docker compose up`, so the port difference is inherited, not introduced by a flag.

The 5000 and 8080 split matters for the spec suites too, because both take the base URL from `HOST`, which the Makefile sets to the same `http://localhost:5000` default the local target uses.

Compose also reads two provider variables from your environment, `ASPNETCORE_Conduit_DatabaseProvider` and `ASPNETCORE_Conduit_ConnectionString`. Leaving either empty hands the container whatever the image defaults to, which is the SQLite path rather than SQL Server.

Startup aborts on a missing, short or placeholder signing key

Before you run the API locally or under Docker, the project insists you create a key:

sh
export Jwt__SigningKey="$(openssl rand -base64 32)"

The requirement is spelled out: at least 32 bytes, base64-encoded, supplied through protected configuration such as a secret manager or the `Jwt__SigningKey` environment variable. Startup fails for missing, malformed, short or obvious placeholder keys. Issuer and audience are treated as identifiers rather than secrets, so they do not need the same handling. Deployments that were running with a former public default key are told to rotate immediately, and managed API test targets generate a fresh ephemeral key when none is supplied.

Rotation is deliberately blunt. Replacing the key on every instance and restarting invalidates every previously issued token, so all users sign in again. Keeping the key stable across restarts and sharing it only among instances of the same deployment is the whole scope of the documented operational guidance; the README does not document any other secret handling or a revocation list.

Only conduit_token_version 2 tokens with a user:<id> subject are accepted

The token rules are the sharpest constraint in the project and the easiest to hit on an existing codebase. Only version-2 tokens carrying `conduit_token_version: "2"` and a stable `user:<id>` subject clear authentication. Legacy username-subject tokens and unversioned tokens are rejected even when the subject happens to look like a new user ID, and there is no compatibility fallback for either case.

That last point is a migration statement rather than a warning. Every existing session must sign in again after this upgrade. The upside is that a renamed user keeps working sessions, because the subject is the ID rather than the name, and authentication rejects tokens belonging to deleted users.

If you port this backend into an application with live users, the token claim change is the work item, not the .NET version bump.

The managed spec-test server deletes the SQLite file it is about to use

Spec testing is handled by targets that manage the server themselves. Fetch the spec first, because it arrives as a git submodule:

sh
git submodule update --init realworld

`make test-hurl-with-managed-server` and `make test-bruno-with-managed-server` then remove `src/Conduit/realworld.db`, start the API, wait for it, run the collection and shut the API down. The sequence is a `define` block in the Makefile:

sh
rm -f src/Conduit/realworld.db; \
Jwt__SigningKey=$${Jwt__SigningKey:-$$(openssl rand -base64 32)} ASPNETCORE_URLS=$(API_URL) dotnet run --project $(PROJECT) & \
timeout 120 bash -c 'until curl -s $(API_URL)/api/tags > /dev/null; do sleep 0.5; done'; \
HOST=$(API_URL) $(1); \
STATUS=$$?; \
kill $$SERVER_PID; \
exit $$STATUS

Two consequences for a real deployment. The database is destroyed without confirmation, so never point these targets at a working directory you care about. Readiness is a poll of `/api/tags` for up to 120 seconds at half-second intervals, and the exit status of the spec runner is propagated after the server is killed, which is why CI can trust the result. The whole thing is POSIX shell, so it needs a shell that understands `define`, `timeout` and `curl`.

The build entry point is a C# project, not build.sh

The primary validation target formats the repository, builds the solution in Release mode and runs the integration tests:

sh
dotnet run --project build/build.csproj -- test

Formatting alone skips the build through the same entry point, and running `build/build.csproj` with no arguments gives the full CI-equivalent build, test and publish pipeline. Build orchestration comes from Bullseye, so the pipeline is a C# program in `build/` rather than a shell script. `build.sh` and `build.ps1` exist at the top level as thin wrappers for macOS, Linux and Windows shells.

The Docker image calls the same target, so local and container builds share one code path:

dockerfile
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
RUN dotnet run --project build/build.csproj -- publish

That is where a close reading finds a mismatch. The README tells contributors to install the SDK pinned in `global.json`, currently `10.0.302`, while the image tags are the floating `10.0` on both the SDK and runtime stages. A machine whose installed patch level differs from the pin and a container built from the current tag are not guaranteed to be the same compiler.

What this reference backend does not settle for you

Treat it as a study, not a platform. The spec suites are the strongest asset: the official RealWorld API spec test collections in Hurl and Bruno run against this implementation and both run in CI through the RealWorld API Tests workflow, so a change that breaks contract compatibility fails the build. Running them locally still costs you a Hurl installation, a Bun installation for the Bruno collection, and the vendored `realworld` submodule checked out.

Beyond that, the documentation stops. There are no GitHub releases, so there is no version history to consult and nothing to pin a deployment to besides a commit: the `global.json` pin at 10.0.302 is the only version number the project names. The README does not document rollback, database migration steps for an existing production database, or how to seed a realistic article set for a demo, and it does not describe how SQL Server support is selected beyond the provider variables passed into the compose file, which defaults to the SQLite file rather than a server on 5000 or 8080.

The topics attached to the repository name the stack honestly: asp-net-core, csharp, entity-framework-core, net-core, realworld. Nothing there promises hosting, observability or a support commitment.

Editorial conclusion

Adopt this backend when your team wants a spec-checked ASP.NET Core starting point and will accept the vertical-slice layout, the EF Core provider switch and the token rules that log out every existing session on upgrade. Do not adopt it as a framework, a tutorial to follow line by line, or a deployment template: its database story starts at a SQLite file, its spec tests depend on a vendored submodule and external runners, and the README does not document rollback or a seeded article set. Before committing, run `dotnet run --project build/build.csproj -- test`, generate your own signing key with `openssl rand -base64 32`, and confirm that `make test-hurl-with-managed-server` passes against your machine before wiring it into anything.

Frequently asked questions

How do I run the aspnetcore-realworld-example-app locally and open its Swagger page?

Run `make run-local`, which sets `ASPNETCORE_URLS` to `http://localhost:5000` and starts `src/Conduit/Conduit.csproj`. Swagger is then at `http://localhost:5000/swagger`; under Docker Compose the service instead publishes port 8080.

Why does the aspnetcore-realworld-example-app fail to start when I set no JWT signing key?

Startup fails for missing, malformed, short or obvious placeholder keys. Export one first with `export Jwt__SigningKey="$(openssl rand -base64 32)"`, which gives the required base64-encoded length of at least 32 bytes.

How do I run the RealWorld Hurl and Bruno API spec tests against aspnetcore-realworld-example-app?

Fetch the spec with `make submodule`, then use `make test-hurl-with-managed-server` or `make test-bruno-with-managed-server`, which need Hurl or Bun respectively. The variants without `-with-managed-server` run against a server you already started with `make run-local`.

Will existing JWT sessions keep working after upgrading aspnetcore-realworld-example-app?

No. Only version-2 tokens with `conduit_token_version: "2"` and a stable `user:<id>` subject are accepted, legacy username-subject and unversioned tokens are rejected with no compatibility fallback, and all existing sessions must sign in again.

Which databases does aspnetcore-realworld-example-app support?

Entity Framework Core with SQLite for local and demo use, plus SQL Server support. Under Docker Compose the provider and connection string are passed as `ASPNETCORE_Conduit_DatabaseProvider` and `ASPNETCORE_Conduit_ConnectionString`.

Official sources

  1. Issues
  2. License: MIT
  3. Project website
  4. README
  5. realworld-apps/aspnetcore-realworld-example-app on GitHub
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/realworld-apps-aspnetcore-realworld-example-app.svg)](https://hysenlabs.com/projects/realworld-apps-aspnetcore-realworld-example-app)