What You Are Building

The service this stage ships: a short-link API on Postgres with authentication, caching, background counting and health checks.

SyntaxPOST /links {"targetUrl": "https://example.com/docs"} -> 201 Created, Location: /links/a7Kd92

This stage builds one service and takes it all the way to a running cluster. The service is a link shortener. A caller sends a long web address and receives a short code. Anyone who visits that code is redirected to the long address. It is small enough to read in an afternoon and large enough to need everything the course has taught.

The endpoints

RequestWho may call itResult
POST /linksA signed-in user201, with the code in the Location header
GET /linksA signed-in userThat user's links, one page at a time
DELETE /links/{code}The owner204 No Content
GET /r/{code}Anyone302 to the target, 404, or 410 if expired

The data

Three tables. users holds an id, an email address and a password hash. links holds an id, the short code, the target address, the owner's id and an optional expiry time. link_clicks holds one row per link per day with a count, written by a background worker rather than by the redirect itself.

The rules, decided now

  • A code is six characters from 0-9A-Za-z, unique, and never reused.
  • A target must be an absolute http or https address.
  • An expired link returns 410, not 404, so a client can tell the difference.
  • The redirect needs no token. Every other endpoint does.

What trips people up

Writing the contract down last. Status codes, field names and the shape of a page are what clients depend on, and they are expensive to change once anyone has used them.

Example

Example · csharp
// The shape every later lesson in this stage builds on.
ShortLink link = new ShortLink(
    Id: Guid.Parse("8f3a1c2e-0000-4000-8000-000000000001"),
    Code: "a7Kd92",
    TargetUrl: "https://example.com/docs",
    OwnerId: Guid.Parse("8f3a1c2e-0000-4000-8000-000000000002"),
    CreatedAt: new DateTimeOffset(2026, 1, 14, 9, 30, 0, TimeSpan.Zero),
    ExpiresAt: null);

// ExpiresAt is nullable because "never expires" is a real answer,
// not a missing one. That decision belongs here, not in the handler.
string expires = link.ExpiresAt is null
    ? "never"
    : link.ExpiresAt.Value.ToString("u");

Console.WriteLine($"code:    {link.Code}");
Console.WriteLine($"target:  {link.TargetUrl}");
Console.WriteLine($"created: {link.CreatedAt.ToString("u")}");
Console.WriteLine($"expires: {expires}");

// A record is a type compared by its values. Stage 2 covers records.
record ShortLink(
    Guid Id,
    string Code,
    string TargetUrl,
    Guid OwnerId,
    DateTimeOffset CreatedAt,
    DateTimeOffset? ExpiresAt);

// Output:
// code:    a7Kd92
// target:  https://example.com/docs
// created: 2026-01-14 09:30:00Z
// expires: never

When to use it

  • Agreeing URL shapes and status codes with a client team before any code exists.
  • Deciding which columns are unique and which may be null before the first migration.
  • Scoping a service so authentication, caching and background work each have one job.

More examples

The three tables the model implies

Reach for this when a data model is still an argument. Written as SQL, the questions that matter are forced into the open: codes are unique at the database level rather than only in the handler, expires_at is nullable on purpose, and clicks are counted per day so the table cannot grow by one row per visitor.

Example · sql
-- The schema EF Core will generate in the next lesson. Reading it as SQL
-- first makes the decisions visible: what is unique, what may be null.
CREATE TABLE users (
    id            uuid PRIMARY KEY,
    email         text NOT NULL UNIQUE,
    password_hash text NOT NULL,
    created_at    timestamptz NOT NULL
);

CREATE TABLE links (
    id         uuid PRIMARY KEY,
    code       text NOT NULL,
    target_url text NOT NULL,
    owner_id   uuid NOT NULL REFERENCES users (id),
    created_at timestamptz NOT NULL,
    expires_at timestamptz NULL          -- NULL means it never expires
);

-- The redirect looks a link up by code on every request, so the code needs
-- an index anyway. Making that index UNIQUE is what stops two links sharing
-- a code even if the application has a bug.
CREATE UNIQUE INDEX ix_links_code ON links (code);

CREATE TABLE link_clicks (
    link_id uuid NOT NULL REFERENCES links (id) ON DELETE CASCADE,
    day     date NOT NULL,
    count   bigint NOT NULL,
    PRIMARY KEY (link_id, day)
);

The request and response bodies

Use this to settle the wire format before writing the API. Printing the serialised form shows exactly what a client will see, including the camelCase names ASP.NET Core applies by default, so the field names can be argued about once rather than renamed after release.

Example · csharp
using System.Text.Json;

// A DTO is a type that exists only to be serialised. It is deliberately not
// the database entity: the entity has an owner id and the client must not.
CreateLinkRequest request = new("https://example.com/docs", null);
LinkResponse response = new("a7Kd92", "https://s.example.com/r/a7Kd92",
    "https://example.com/docs", 0);

JsonSerializerOptions options = new()
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    WriteIndented = false
};

Console.WriteLine(JsonSerializer.Serialize(request, options));
Console.WriteLine(JsonSerializer.Serialize(response, options));

record CreateLinkRequest(string TargetUrl, DateTimeOffset? ExpiresAt);

record LinkResponse(string Code, string ShortUrl, string TargetUrl, long Clicks);

// Output:
// {"targetUrl":"https://example.com/docs","expiresAt":null}
// {"code":"a7Kd92","shortUrl":"https://s.example.com/r/a7Kd92","targetUrl":"https://example.com/docs","clicks":0}

The whole route table, handlers still empty

A useful first commit on any new service. It compiles and runs, so the paths, the verbs and the status codes can be reviewed, and a client team can start against it, while each handler is filled in one at a time. Running it and calling POST /links returns 201 with a Location header and echoes the body back.

Example · csharp
// Shortly.Api/Program.cs - every route, every status code, no logic yet.
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
WebApplication app = builder.Build();

RouteGroupBuilder links = app.MapGroup("/links");

links.MapPost("/", (CreateLink request) =>
    TypedResults.Created("/links/a7Kd92", request));

links.MapGet("/", () => TypedResults.Ok(Array.Empty<string>()));

links.MapDelete("/{code}", (string code) => TypedResults.NoContent());

// permanent: false means 302 Found, so the redirect is not cached for ever.
app.MapGet("/r/{code}", (string code) =>
    TypedResults.Redirect("https://example.com/docs", permanent: false));

app.Run();

public record CreateLink(string TargetUrl, DateTimeOffset? ExpiresAt);

Discussion

  • Be the first to comment on this lesson.