Sr. Content Developer at Microsoft, working remotely in PA, TechBash conference organizer, former Microsoft MVP, Husband, Dad and Geek.
155642 stories
·
33 followers

Creating a multi-agent application. Part 2

1 Share

In my previous post, I showed the output of a multi-agent application I wrote to create blog posts (not to worry, it is for demonstration purposes only). In this post, I will begin the process of working through the code, line by line.

This application is written in Python, in a Colab notebook, using (among other things) LangChain and LangGraph. To follow along you will need to obtain an API key from OpenAI and a key from Tavily.

If you are a C# programmer with little or no Python experience, don’t panic! Python is pretty readable, and I’ll explain any part that is potentially obscure or confusing.

This will be a multi-agent application. The agents we’ll create will be:

  • Blogger which will orchestrate the others
  • Researcher, which will search the web for relevant information
  • Author, which will write drafts of the blog post
  • Reviewer, which will evaluate the drafts and suggest improvements

As a general rule, I try to limit the number of agents to 3-5. Any more than that can get terribly complicated with diminishing returns. Your mileage may vary.

Set Up

The program begins by importing the necessary libraries.

!pip install -q openai==1.66.3 \
                langchain==0.3.20 \
                langchain-openai==0.3.9 \
                langchain_experimental==0.3.4 \
                langchain-tavily==0.2.4 \
                tavily-python==0.5.0 \
                langgraph==0.3.21 \
                langgraph-supervisor==0.0.18

You then load your OpenAI API key and Tavily API key from either a configuration file (which is what I do here) or from the environment.

import json
import os
from pprint import pprint

file_name = 'config.json'
with open(file_name, 'r') as file:
    config = json.load(file)
    os.environ['OPENAI_API_KEY'] = config.get("API_KEY")
    os.environ["OPENAI_BASE_URL"] = config.get("OPENAI_API_BASE")
    os.environ["TAVILY_API_KEY"] = config.get("TAVILY_API_KEY")

Next, we set up the model.

from langchain_openai import ChatOpenAI

model_name = 'gpt-4o-mini'

llm = ChatOpenAI(
    model = model_name,
    temperature=0,
    max_tokens=4096
)

I’ve opted for the gpt-4o-mini model, as it’s the most cost-effective option. I’ve set the temperature to 0 to get the most consistent results.

Tavily is a tool for searching the web, so let’s set that up as well

from langchain_tavily import TavilySearch

tavily_tool = TavilySearch(
    max_results=5,
    topic="general",
    include_answer=False,
    include_raw_content=False,
    search_depth="basic"
)

You can find all the options for this on the Tavily website.

I’m going to want a state object so that I can pass it among the agents.

from typing import TypedDict, Annotated, List
from langgraph.graph import StateGraph, END
import operator

class ResearchState(TypedDict):
    """State for the research workflow."""
    main_task: str
    research_findings: Annotated[List[str], operator.add]
    draft: str
    review_notes: str
    revision_number: int
    next_step: str
    current_sub_task: str

Blogger

To get started, we’ll create the (non-trivial) blogger agent. This is actually the most powerful and thus the most complex of the agents.

We begin with the prompt template (this is, essentially, the system prompt)

blogger_prompt_template = """You are a blogger managing a blog post creation workflow.

Current Task: {main_task}

Current State:
- Research Findings: {research_findings}
- Blog Draft: {draft}
- Reviewer Feedback: {review_notes}
- Revision Number: {revision_number}

Your goal is to ensure a clear, engaging, and valuable blog post targeted at software developers.

Decide the next step and respond only with a JSON object (no extra text):
{
  "next_step": "researcher" or "author" or "END",
  "task_description": "Brief description of what needs to be done next"
}

Decision Rules:
- If no research exists, choose "researcher"
- If research exists but no draft, choose "author"
- If draft exists and critic says "APPROVED", choose "END"
- If draft needs revision, choose "author"
- If revision_number >= 4, choose "END"
"""

Take a moment to read this over; it sets the parameters and goals of the program. The decision rules are critical, they control the flow, and they set a limit on revisions (in this case 4).

Having told the Blogger what we are trying to accomplish and the general tone of the output we’re ready to create the decision tree that constitutes the workflow for the Blogger. This is pretty long, but most of it is self-explanatory.

def create_blogger_chain():
    """Creates the bloger decision chain."""

    def blogger_invoke(state):
        research = state.get("research_findings", [])
        research_text = "\n".join(research) if research else "No research yet."
        revision = state.get("revision_number", 0)
        has_research = len(research) > 0
        has_draft = bool(state.get("draft", "").strip())
        review = state.get("review_notes", "")

        if "APPROVED" in review.upper() and has_draft:
            print("Blogger: Draft approved, ending workflow")
            return {
                "next_step": "END",
                "task_description": "Report approved and complete"
            }

        if not has_research:
            print("Blogger: No research yet, directing to researcher")
            return {
                "next_step": "researcher",
                "task_description": f"Research the topic: {state.get('main_task', '')}"
            }


        if has_research and not has_draft:
            print("Blogger: Have research, creating first draft")
            return {
                "next_step": "author",
                "task_description": "Write the first draft based on research findings"
            }

        if has_draft and not review:
            print("Blogger: Have draft, sending to reviewer")
            return {
                "next_step": "author",
                "task_description": "Prepare draft for review"
            }

        if review and "APPROVED" not in review.upper() and revision <= 4:
            print(f"Supervisor: Revision {revision}, sending back to author")
            return {
                "next_step": "author",
                "task_description": "Revise the draft based on review feedback"
            }

        if revision >= 4:
            print("Blogger: Max revisions reached! Ending")
            return {
                "next_step": "END",
                "task_description": "Maximum revisions reached! Finalizing report"
            }

        # LLM as fallback
        prompt = blogger_prompt_template.format(
            main_task=state.get("main_task", ""),
            research_findings=research_text,
            draft=state.get("draft", "No draft yet."),
            review_notes=review if review else "No review yet.",
            revision_number=revision
        )

        try:
            response = llm.invoke(prompt)
            content = response.content if hasattr(response, 'content') else str(response)

            text = content.strip()
            if text.startswith("```"):
                lines = text.split("\n")
                text = "\n".join([l for l in lines if not l.strip().startswith("```")])
            text = text.strip()

            decision = json.loads(text)

            if "next_step" in decision:
                return decision

        except Exception as e:
            print(f"LLM parsing error: {e}")

        # Final fallback 
        print("Blogger: Using final fallback - continuing with author")
        return {
            "next_step": "author",
            "task_description": "Continue with draft creation"
        }

    return blogger_invoke

# Creating a callable object
blogger_chain = create_blogger_chain()

We begin with a nested function.

def create_blogger_chain():
"""Creates the blogger decision chain."""

def blogger_invoke(state):
...

When called, Python executes the code inside it, that is, the inner function. In this case, the inner function does the real work.

We define a second function inside create_blogger_chain. We do this to create a closure, that is the inner function can access variables from the outer function. This is a common construct when working with langchain. The inner function has access to the llm without it being passed every time. So, in short, create_blogger_chain is actually a factory function that constructs and configures a callable function and then returns it.

We next set up our variables based on starting values in the state object. With that done, we’re ready to progress through a series of possible conditions. These are pretty self-explanatory and follow the rules established in the template.

All that’s left for the blogger is to create a node where the decision will be implemented. We’ll use this node, and the others we’ll create, when we implement the workflow (after we define all the agents).

def blogger_node(state: ResearchState) -> dict:
    """Blogger decides the next step."""
    print("\n>>>Blogger")

    decision = blogger_chain(state)

    next_step = decision.get("next_step", "researcher")
    task_desc = decision.get("task_description", "Continue work")

    print(f"Decision: {next_step}")
    print(f"Task: {task_desc}")

    return {
        "next_step": next_step,
        "current_sub_task": task_desc,
    }

Note that the variable decision is assigned as a result of calling the code we just reviewed. With that in hand, we call get on the decision, asking for the next step. If none is returned, we use researcher.

That’s it for blogger. We’ll review the other agents in the next posting.

Read the whole story
alvinashcraft
just a second ago
reply
Pennsylvania, USA
Share this story
Delete

Introducing Corvus.Text.Json V5: Mutable Documents

1 Share

At endjin, we maintain Corvus.JsonSchema, and in the previous post we looked at pooled-memory parsing with ParsedJsonDocument<T>.

Now let's look at the other side of the coin: mutation.

The V4/V5 trade-off

This is the fundamental design decision in V5, so it's worth being explicit about it.

In V4, every document is immutable. If you want to change a property, you call a With*() method that returns a new instance with the modification applied. The old instance is unchanged. This is the functional approach. It is safe, thread-friendly, and easy to reason about.

V4 is smarter than a naive copy-on-write: it avoids copying unmodified parts of the document and defers serialization to write operations. But it still has to create new immutable data structures wherever objects or arrays are modified. In a pipeline where you parse, modify, and write JSON repeatedly, that adds up to a lot of short-lived allocations.

V5 takes the opposite approach. JsonDocumentBuilder lets you mutate documents in place, using pooled memory managed by a JsonWorkspace. You create a workspace, build or modify documents, write the output, and dispose everything. The workspace recycles the memory for the next operation.

This is the builder approach. It is fast and low-allocation, but you need to be mindful of lifetimes and ownership. V5 includes version tracking to catch stale references at runtime, which mitigates the most common class of bugs.

Neither approach is universally better. If you want safety guarantees, use V4. If you want maximum throughput, use V5.

The workspace

Every mutable operation starts with a JsonWorkspace:

using JsonWorkspace workspace = JsonWorkspace.Create();

The workspace manages pooled buffers and Utf8JsonWriter instances. When you dispose it, all resources go back to the pool. You can create multiple builders within a single workspace. They share the pooled resources.

Building from scratch

The most common pattern is building an object using a builder delegate:

using JsonWorkspace workspace = JsonWorkspace.Create();

using var doc = JsonElement.CreateBuilder(
    workspace,
    new(static (ref objectBuilder) =>
    {
        objectBuilder.AddProperty("name"u8, "Alice"u8);
        objectBuilder.AddProperty("age"u8, 30);
        objectBuilder.AddProperty("active"u8, true);
    }));

Console.WriteLine(doc.RootElement.ToString());
// {"name":"Alice","age":30,"active":true}

That new(...) is a target-typed new. The compiler knows it needs a JsonElement.Source from the CreateBuilder parameter type. The static modifier on the delegate prevents accidental closure allocations. Use UTF-8 string literals (u8) for property names to avoid transcoding overhead.

What is Source?

JsonElement.Source is a ref struct that acts as a discriminated union. It can hold any value that might appear in a JSON document. It has implicit conversions from over 30 .NET types, so you rarely need to think about it:

  • Primitives: bool, int, long, double, decimal, float, short, byte, and the unsigned variants, plus Half, Int128, UInt128
  • Strings: string, ReadOnlySpan<char>, ReadOnlySpan<byte> (UTF-8)
  • Dates: DateTime, DateTimeOffset, and NodaTime types (LocalDate, OffsetDateTime, Period, etc.)
  • Other: Guid, Uri, BigNumber, BigInteger, JsonElement, JsonElement.Mutable
  • Delegates: JsonElement.ObjectBuilder.Build for nested objects, JsonElement.ArrayBuilder.Build for nested arrays

This is what makes the builder API feel natural. JsonElement.ObjectBuilder.AddProperty has direct overloads for all these types, so objectBuilder.AddProperty("age"u8, 30) just works. The int matches directly. For CreateBuilder and SetProperty, your value implicitly converts to a Source. Either way, you just pass values. The type system handles the rest.

For objects and arrays, you pass a builder delegate:

// Object builder - delegate receives ref JsonElement.ObjectBuilder
objectBuilder.AddProperty("address"u8, static (ref addressBuilder) =>
{
    addressBuilder.AddProperty("city"u8, "London"u8);
});

// Array builder - delegate receives ref JsonElement.ArrayBuilder
objectBuilder.AddProperty("tags"u8, static (ref tagsBuilder) =>
{
    tagsBuilder.AddItem("admin"u8);
    tagsBuilder.AddItem("user"u8);
});

There's also a generic Source<TContext> variant for passing context to a delegate without allocating a closure - useful in hot paths where even a single delegate allocation matters.

Nested objects and arrays

Builder delegates compose naturally:

using var doc = JsonElement.CreateBuilder(
    workspace,
    new(static (ref objectBuilder) =>
    {
        objectBuilder.AddProperty("user"u8, static (ref userBuilder) =>
        {
            userBuilder.AddProperty("name"u8, "Alice"u8);
            userBuilder.AddProperty("roles"u8, static (ref rolesBuilder) =>
            {
                rolesBuilder.AddItem("admin"u8);
                rolesBuilder.AddItem("editor"u8);
            });
        });
    }));

// {"user":{"name":"Alice","roles":["admin","editor"]}}

Parse-and-mutate

In most real-world scenarios, you're receiving JSON, modifying it, and sending it on. Parse directly into a mutable builder for the best performance:

using JsonWorkspace workspace = JsonWorkspace.Create();

// Single pass - UTF-8 bytes become the builder's backing store
using var builder = JsonDocumentBuilder<JsonElement.Mutable>.Parse(
    workspace,
    """{"status":"pending","count":5}""");

JsonElement.Mutable root = builder.RootElement;
root.SetProperty("status", "completed"u8);
root.SetProperty("count", 10);

Console.WriteLine(root.ToString());
// {"status":"completed","count":10}

All the same Parse overloads are available - from strings, UTF-8 bytes, streams, or a Utf8JsonReader.

Retaining the original

If you need to keep an immutable copy alongside the mutable version - for auditing, comparison, or read-only queries - use the two-step approach:

using JsonWorkspace workspace = JsonWorkspace.Create();

using var sourceDoc = ParsedJsonDocument<JsonElement>.Parse(
    """{"name":"Original","value":100}""");

// Convert to mutable - sourceDoc remains unchanged
using var builder = sourceDoc.RootElement.CreateBuilder(workspace);

builder.RootElement.SetProperty("name", "Modified"u8);

Console.WriteLine(sourceDoc.RootElement.ToString());  // {"name":"Original",...}
Console.WriteLine(builder.RootElement.ToString());     // {"name":"Modified",...}

Version tracking

Here's how V5 catches the aliasing bugs that in-place mutation can introduce.

Every JsonDocumentBuilder tracks a ulong version number. When you obtain a mutable element reference, it captures the current version. If you modify the document through a different reference and then try to use the stale one, V5 throws InvalidOperationException:

JsonElement.Mutable root = builder.RootElement;
JsonElement.Mutable name = root.GetProperty("name");

// Mutate through root - this bumps the version
root.SetProperty("name", "Changed"u8);

// Try to use the stale reference
name.GetString();  // throws InvalidOperationException

This won't catch every possible misuse, but it catches the most common class of bugs: holding a reference across a mutation boundary.

Clone and freeze

When you're working with a mutable document, you sometimes need an immutable copy. It is a value that won't go stale when you mutate the builder again. Freeze() gives you exactly that.

Freeze() performs a fast blit of the metadata and value backing arrays into a new immutable document registered in the same workspace, without a serialization round-trip. The result is immutable, and you can keep mutating the original builder while the frozen element stays valid:

using JsonWorkspace workspace = JsonWorkspace.Create();
using var doc = ParsedJsonDocument<JsonElement>.Parse("""{"name": "Alice", "age": 30}""");
using var builder = doc.RootElement.CreateBuilder(workspace);

builder.RootElement.SetProperty("age"u8, 31);

// Freeze - cheap immutable copy, stays in the workspace
JsonElement frozen = builder.RootElement.Freeze();

// Keep mutating - the frozen element is unaffected
builder.RootElement.SetProperty("age"u8, 99);

Assert.Equal(31, frozen.GetProperty("age"u8).GetInt32()); // still 31

The frozen element is tied to the workspace's lifetime. It's backed by pooled memory and will be cleaned up with the workspace.

On the other hand, if you need data to escape the workspace entirely (e.g. to return it to a caller who shouldn't need to worry about workspaces or lifetimes) then that's what Clone() is for.

Clone() serializes the mutable element into a fresh immutable ParsedJsonDocument that owns its own memory on the GC heap. The clone is completely independent of both the builder and the workspace, so it remains valid after both are disposed:

JsonElement clone;

using (JsonWorkspace workspace = JsonWorkspace.Create())
using (var doc = ParsedJsonDocument<JsonElement>.Parse("[[[]]]"))
using (var builder = doc.RootElement.CreateBuilder(workspace))
{
    clone = builder.RootElement[0].Clone();
    // builder and workspace are disposed here
}

// clone is still valid - it owns its own memory
Assert.Equal("[[]]", clone.GetRawText());

Use Freeze() for cheap immutable copies within the workspace scope. Use Clone() when the result needs to escape entirely.

Snapshots for rollback

JsonDocumentBuilderSnapshot<T> captures the complete state of a builder so you can restore it later. This is useful for speculative mutations where you may want to roll back if something goes wrong. We mentioned this briefly in the context of JSON Patch, but it's a general-purpose mechanism.

using JsonWorkspace workspace = JsonWorkspace.Create();
using var doc = ParsedJsonDocument<JsonElement>.Parse("""{"status": "pending", "retries": 0}""");
using var builder = doc.RootElement.CreateBuilder(workspace);

// Take a snapshot before applying changes
using var snapshot = builder.CreateSnapshot();

builder.RootElement.SetProperty("status"u8, "processing");
builder.RootElement.SetProperty("retries"u8, 1);

// Something went wrong - roll back to the snapshot
builder.Restore(snapshot);

Assert.Equal("pending", builder.RootElement.GetProperty("status"u8).GetString());
Assert.Equal(0, builder.RootElement.GetProperty("retries"u8).GetInt32());

CreateSnapshot() creates a rented copy of the builder's internal state, and Restore() copies it back. The snapshot is IDisposable and must be disposed to return the rented buffers to the pool.

Dynamic construction with runtime data

Real-world JSON isn't all static strings. Here's how you mix structure with runtime data:

string[] tags = ["admin", "user", "active"];

using JsonWorkspace workspace = JsonWorkspace.Create();

using var doc = JsonElement.CreateBuilder(
    workspace,
    new((ref objectBuilder) =>
    {
        objectBuilder.AddProperty("id"u8, Guid.NewGuid());

        // Runtime collection becomes a JSON array
        objectBuilder.AddProperty("tags"u8, (ref tagsBuilder) =>
        {
            foreach (string tag in tags)
            {
                tagsBuilder.AddItem(tag);
            }
        });
    }));

The delegate in this example captures tags from the enclosing scope, so it can't be static. That's often fine, but if you need to avoid the closure allocation there is a Source<TContext> overload that lets you pass context explicitly:

string[] tags = ["admin", "user", "active"];

using JsonWorkspace workspace = JsonWorkspace.Create();

using var doc = JsonElement.CreateBuilder(
    workspace,
    tags,
    static (in string[] tags, ref JsonElement.ObjectBuilder objectBuilder) =>
    {
        objectBuilder.AddProperty("id"u8, Guid.NewGuid());

        objectBuilder.AddProperty("tags"u8, tags,
            static (in string[] tags, ref JsonElement.ArrayBuilder tagsBuilder) =>
            {
                foreach (string tag in tags)
                {
                    tagsBuilder.AddItem(tag);
                }
            });
    });

The context parameter is passed by in reference, so there is no copying or boxing. Every delegate is static, so there are no closure allocations.

Generated types and the builder

Everything in this post uses JsonElement and JsonElement.Mutable, but the same builder API works for all generated types. If you have a Person type generated from a JSON Schema, you can create a builder, mutate it, and freeze it in exactly the same way.

The difference is that the generated types only emit .NET members that are compatible with the constraints in their schema. A Person.Mutable will have SetProperty for the properties defined in the schema, and conversions to and from .NET types that match the schema's type constraints. For example, numeric conversions are only available if the schema allows the value to be a number. This means the compiler catches type errors at build time rather than at runtime.

At a glance

JsonNode V4 With*() V5 JsonDocumentBuilder
Mutation In-place, per-node Returns new immutable instance In-place, pooled
Memory Managed heap per node Managed heap per copy ArrayPool via workspace
Safety No aliasing protection Immutability prevents aliasing Version-tracked stale detection
Best for Long-lived trees Safety-critical pipelines High-throughput request/response

Next up

In the next post, we'll look at the standalone evaluator. It's a lightweight code generation mode that produces just a validator and annotation collector, without the full type hierarchy. It's ideal for schema-driven tooling like form generators and configuration editors.



Read the whole story
alvinashcraft
34 seconds ago
reply
Pennsylvania, USA
Share this story
Delete

From Flutter to Backend: How to Build Production-Grade REST APIs with Dart and Dart Frog

1 Share

Dart backend frameworks exist on a spectrum. At the minimal end sits Shelf, with raw primitives and full control. You wire everything yourself. At the maximal end sits Serverpod. It's a full framework with code generation and opinionated conventions. The framework makes most structural decisions for you.

Dart Frog lives in the middle, and for many Flutter engineers, it's the most natural fit.

Dart Frog is a fast, minimalistic backend framework built on top of Shelf, originally created by Very Good Ventures and now maintained independently. It takes the file-based routing model popularized by Next.js and Remix, applies it to Dart, and wraps it with a clean CLI that handles development server, hot reload, production builds, and Docker generation, all out of the box.

You write a Dart file in the routes/ directory, export an onRequest function, and Dart Frog handles the routing automatically. No router configuration, no handler registration, no mounting. The file system is the router.

In this article, we'll build a User and Profile Management REST API (the same one we built in the linked articles above) using Dart Frog, connect it to PostgreSQL, add JWT authentication, and deploy it to Fly.io.

By the end you'll understand Dart Frog's routing model deeply, and you'll have a clear picture of where it fits compared to Shelf and Serverpod.

Table of Contents

Prerequisites

Before starting, you should have:

  • Comfortable familiarity with Dart and Flutter development

  • Understanding of REST API concepts, endpoints, HTTP methods, status codes

  • Docker Desktop installed and running

  • A Fly.io account for deployment

How Dart Frog Differs from Shelf and Serverpod

Understanding where Dart Frog sits in relation to the other two frameworks helps you make the right choice for each project.

Shelf gives you a Router and you mount handlers manually. Your folder structure has nothing to do with your URL structure. You decide what goes where.

Serverpod generates your routes from endpoint class names and method names. You define a class, run a generator, and the URL is derived automatically.

Dart Frog maps your file system directly to your URL structure. A file at routes/users/index.dart becomes the /users endpoint. A file at routes/users/[id].dart becomes /users/:id. No configuration, no registration, no generation step. The file is the route.

This model will feel immediately intuitive to Flutter engineers who have worked with Next.js or any modern web framework. It's also significantly easier to navigate in a team. You look at the folder structure and you instantly know what endpoints exist.

The other key difference is the RequestContext. Where Shelf passes a raw Request to handlers, Dart Frog wraps it in a RequestContext that carries both the request and any values injected by middleware. This is Dart Frog's dependency injection mechanism, and it's elegant.

Installing Dart Frog

Install the Dart Frog CLI:

dart pub global activate dart_frog_cli

Verify the installation:

dart_frog --version

Creating the Project

dart_frog create user_profile_api
cd user_profile_api

Start the development server with hot reload:

dart_frog dev

Visit http://localhost:8080 and you'll see the default welcome response. The dev server watches for file changes and reloads automatically. No restart needed as you build.

Understanding the Project Structure

user_profile_api/
  routes/
    index.dart              ← GET /
  pubspec.yaml
  analysis_options.yaml

That's the entire starting structure. Clean and minimal. Everything we add will extend from here.

After building our API, the full structure will look like this:

user_profile_api/
  routes/
    _middleware.dart         ← global middleware pipeline
    index.dart               ← GET /
    auth/
      login.dart             ← POST /auth/login
      register.dart          ← POST /auth/register
    users/
      index.dart             ← GET /users
      [id].dart              ← GET, PUT, DELETE /users/:id
      [id]/
        profile.dart         ← GET, POST, PUT /users/:id/profile
  lib/
    config/
      database.dart
      env.dart
    models/
      user.dart
      profile.dart
    repositories/
      user_repository.dart
      profile_repository.dart
    services/
      auth_service.dart
    middleware/
      auth_middleware.dart
      error_middleware.dart
  pubspec.yaml

The routes/ folder is the heart of a Dart Frog project. The lib/ folder holds all shared logic that routes import. This separation is clean and deliberate: routing concerns live in routes/, while business logic lives in lib/.

Dart Frog Core Concepts

File-Based Routing

Every .dart file in the routes/ directory is a route. The file path determines the URL path:

File URL
routes/index.dart /
routes/users/index.dart /users
routes/users/[id].dart /users/:id
routes/auth/login.dart /auth/login
routes/users/[id]/profile.dart /users/:id/profile

Every route file must export an onRequest function:

import 'package:dart_frog/dart_frog.dart';

Future<Response> onRequest(RequestContext context) async {
  return Response.json(body: {'message': 'Hello from Dart Frog'});
}

That's the entire contract. One function, one file, one route. Dart Frog generates the internal routing glue automatically when you run dart_frog dev or dart_frog build.

The RequestContext

RequestContext is the object passed to every route handler and middleware. It's more than just the HTTP request: it's a container for the request and any values that middleware has injected:

Future<Response> onRequest(RequestContext context) async {
  // The raw HTTP request
  final request = context.request;

  // HTTP method
  print(request.method); // GET, POST, etc.

  // Path parameters (for dynamic routes like [id].dart)
  final id = context.request.uri.pathSegments.last;

  // Query parameters
  final page = request.uri.queryParameters['page'];

  // Request body
  final body = await request.json() as Map<String, dynamic>;

  // Values injected by middleware
  final db = context.read<DatabaseConnection>();
  final currentUser = context.read<AuthenticatedUser>();

  return Response.json(body: {'ok': true});
}

context.read() is the dependency injection mechanism. Middleware provides values, and routes consume them. This keeps routes clean and testable: a route handler doesn't know how a database connection was created, it just reads it from context.

Middleware and Dependency Injection

A _middleware.dart file in any route folder applies middleware to all routes in that folder and its subfolders. A _middleware.dart at the root routes/ level applies globally.

Middleware in Dart Frog uses the provider pattern to inject values into the context:

import 'package:dart_frog/dart_frog.dart';

Handler middleware(Handler handler) {
  return handler.use(
    provider<DatabaseConnection>(
      (context) => DatabaseConnection.instance,
    ),
  );
}

Any route in the same folder, or any subfolder, can then call context.read() to get the connection. No global singletons, no manual passing. The context carries it.

Middleware functions can also intercept requests before they reach the route handler, making them perfect for authentication:

Handler middleware(Handler handler) {
  return (context) async {
    final authHeader = context.request.headers['authorization'];

    if (authHeader == null) {
      return Response.json(
        statusCode: 401,
        body: {'error': 'Authorization required'},
      );
    }

    // Verify token and inject user
    final user = verifyToken(authHeader);
    return handler(context.provide<AuthenticatedUser>(() => user));
  };
}

Dynamic Routes

A file named [id].dart matches any single path segment. Inside the handler, extract the parameter from the URL:

Future<Response> onRequest(RequestContext context, String id) async {
  // id is automatically passed as a parameter for dynamic routes
  return Response.json(body: {'userId': id});
}

Dart Frog passes dynamic route parameters as additional arguments to onRequest. This is cleaner than parsing them manually from the URL.

Setting Up the Database

Docker Compose for PostgreSQL

Create docker-compose.yml in the project root:

version: '3.8'

services:
  postgres:
    image: postgres:16-alpine
    container_name: user_profile_db
    environment:
      POSTGRES_DB: user_profile_api
      POSTGRES_USER: dart_user
      POSTGRES_PASSWORD: dart_password
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U dart_user -d user_profile_api"]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  postgres_data:

Start the database:

docker compose up -d

Environment Configuration

Add dependencies to pubspec.yaml:

dependencies:
  dart_frog: ^1.4.0
  dart_frog_auth: ^0.1.0
  postgres: ^3.3.0
  dart_jsonwebtoken: ^2.12.0
  bcrypt: ^1.1.3
  dotenv: ^4.1.0

dev_dependencies:
  dart_frog_cli: ^1.2.0
  test: ^1.24.0
  dart_frog_test: ^0.1.0

Run dart pub get.

Create .env:

DB_HOST=localhost
DB_PORT=5432
DB_NAME=user_profile_api
DB_USER=dart_user
DB_PASSWORD=dart_password
JWT_SECRET=your_super_secret_key_change_this_in_production
JWT_EXPIRY_HOURS=24
PORT=8080

Create lib/config/env.dart:

import 'package:dotenv/dotenv.dart';

class Env {
  static late final DotEnv _env;

  static void load() {
    _env = DotEnv(includePlatformEnvironment: true)..load();
  }

  static String get dbHost => _env['DB_HOST'] ?? 'localhost';
  static int get dbPort => int.parse(_env['DB_PORT'] ?? '5432');
  static String get dbName => _env['DB_NAME'] ?? 'user_profile_api';
  static String get dbUser => _env['DB_USER'] ?? 'dart_user';
  static String get dbPassword => _env['DB_PASSWORD'] ?? '';
  static String get jwtSecret => _env['JWT_SECRET'] ?? '';
  static int get jwtExpiryHours =>
      int.parse(_env['JWT_EXPIRY_HOURS'] ?? '24');
}

Database Connection Manager

Create lib/config/database.dart:

import 'package:postgres/postgres.dart';
import 'env.dart';

class Database {
  static Connection? _connection;

  static Future<Connection> get connection async {
    if (_connection != null) return _connection!;
    _connection = await Connection.open(
      Endpoint(
        host: Env.dbHost,
        port: Env.dbPort,
        database: Env.dbName,
        username: Env.dbUser,
        password: Env.dbPassword,
      ),
      settings: const ConnectionSettings(sslMode: SslMode.disable),
    );
    print('Database connected');
    return _connection!;
  }

  static Future<void> runMigrations() async {
    final conn = await connection;
    await conn.execute('''
      CREATE TABLE IF NOT EXISTS users (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        email VARCHAR(255) UNIQUE NOT NULL,
        password_hash VARCHAR(255) NOT NULL,
        first_name VARCHAR(100) NOT NULL,
        last_name VARCHAR(100) NOT NULL,
        is_active BOOLEAN DEFAULT TRUE,
        created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
        updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
      );

      CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);

      CREATE TABLE IF NOT EXISTS profiles (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
        bio TEXT,
        avatar_url VARCHAR(500),
        phone VARCHAR(20),
        location VARCHAR(255),
        website VARCHAR(500),
        created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
        updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
        UNIQUE(user_id)
      );

      CREATE INDEX IF NOT EXISTS idx_profiles_user_id ON profiles(user_id);
    ''');
    print('Migrations applied');
  }
}

Migrations

Dart Frog projects have a main.dart entry point generated during dart_frog build. For the development server, migrations are best run from the project entrypoint. Create main.dart in the project root:

import 'dart:io';
import 'package:dart_frog/dart_frog.dart';
import 'lib/config/database.dart';
import 'lib/config/env.dart';

Future<HttpServer> run(Handler handler, InternetAddress ip, int port) async {
  Env.load();
  await Database.runMigrations();
  return serve(handler, ip, port);
}

This run function is Dart Frog's server lifecycle hook. It runs before the server starts accepting requests, giving us the right place to load environment variables and run migrations.

Defining the Models

With the database layer in place, we need Dart classes to represent the data coming in and out of it.

The User model maps to the users table and handles conversion between database rows and Dart objects. The Profile model does the same for the profiles table. Both models follow the same pattern: a factory constructor for reading from the database and a toJson method for sending data back to the client.

Note that toJson on the User model deliberately excludes the password hash. You should never return credential data in an API response.

Create lib/models/user.dart:

class User {
  const User({
    required this.id,
    required this.email,
    required this.passwordHash,
    required this.firstName,
    required this.lastName,
    required this.isActive,
    required this.createdAt,
    required this.updatedAt,
  });

  final String id;
  final String email;
  final String passwordHash;
  final String firstName;
  final String lastName;
  final bool isActive;
  final DateTime createdAt;
  final DateTime updatedAt;

  factory User.fromRow(Map<String, dynamic> row) => User(
        id: row['id'] as String,
        email: row['email'] as String,
        passwordHash: row['password_hash'] as String,
        firstName: row['first_name'] as String,
        lastName: row['last_name'] as String,
        isActive: row['is_active'] as bool,
        createdAt: row['created_at'] as DateTime,
        updatedAt: row['updated_at'] as DateTime,
      );

  Map<String, dynamic> toJson() => {
        'id': id,
        'email': email,
        'firstName': firstName,
        'lastName': lastName,
        'isActive': isActive,
        'createdAt': createdAt.toIso8601String(),
        'updatedAt': updatedAt.toIso8601String(),
      };
}

Create lib/models/profile.dart:

class Profile {
  const Profile({
    required this.id,
    required this.userId,
    this.bio,
    this.avatarUrl,
    this.phone,
    this.location,
    this.website,
    required this.createdAt,
    required this.updatedAt,
  });

  final String id;
  final String userId;
  final String? bio;
  final String? avatarUrl;
  final String? phone;
  final String? location;
  final String? website;
  final DateTime createdAt;
  final DateTime updatedAt;

  factory Profile.fromRow(Map<String, dynamic> row) => Profile(
        id: row['id'] as String,
        userId: row['user_id'] as String,
        bio: row['bio'] as String?,
        avatarUrl: row['avatar_url'] as String?,
        phone: row['phone'] as String?,
        location: row['location'] as String?,
        website: row['website'] as String?,
        createdAt: row['created_at'] as DateTime,
        updatedAt: row['updated_at'] as DateTime,
      );

  Map<String, dynamic> toJson() => {
        'id': id,
        'userId': userId,
        'bio': bio,
        'avatarUrl': avatarUrl,
        'phone': phone,
        'location': location,
        'website': website,
        'createdAt': createdAt.toIso8601String(),
        'updatedAt': updatedAt.toIso8601String(),
      };
}

Building the Repositories

Repositories are the single point of contact between the application and the database. Rather than writing SQL directly inside route handlers, we'll centralise all database operations here. This keeps the handlers clean and makes the data access logic easy to find, maintain, and test independently.

The UserRepository handles every operation on the users table. The ProfileRepository does the same for profiles, using userId as its primary lookup key since profiles are always accessed in the context of a specific user.

User Repository

Create lib/repositories/user_repository.dart:

import 'package:postgres/postgres.dart';
import '../config/database.dart';
import '../models/user.dart';

class UserRepository {
  Future<Connection> get _conn => Database.connection;

  Future<List<User>> findAll() async {
    final conn = await _conn;
    final results = await conn.execute(
      'SELECT * FROM users WHERE is_active = TRUE ORDER BY created_at DESC',
    );
    return results.map((r) => User.fromRow(r.toColumnMap())).toList();
  }

  Future<User?> findById(String id) async {
    final conn = await _conn;
    final results = await conn.execute(
      Sql.named('SELECT * FROM users WHERE id = @id AND is_active = TRUE'),
      parameters: {'id': id},
    );
    if (results.isEmpty) return null;
    return User.fromRow(results.first.toColumnMap());
  }

  Future<User?> findByEmail(String email) async {
    final conn = await _conn;
    final results = await conn.execute(
      Sql.named('SELECT * FROM users WHERE email = @email'),
      parameters: {'email': email},
    );
    if (results.isEmpty) return null;
    return User.fromRow(results.first.toColumnMap());
  }

  Future<User> create({
    required String email,
    required String passwordHash,
    required String firstName,
    required String lastName,
  }) async {
    final conn = await _conn;
    final results = await conn.execute(
      Sql.named('''
        INSERT INTO users (email, password_hash, first_name, last_name)
        VALUES (@email, @passwordHash, @firstName, @lastName)
        RETURNING *
      '''),
      parameters: {
        'email': email,
        'passwordHash': passwordHash,
        'firstName': firstName,
        'lastName': lastName,
      },
    );
    return User.fromRow(results.first.toColumnMap());
  }

  Future<User?> update({
    required String id,
    String? firstName,
    String? lastName,
  }) async {
    final conn = await _conn;
    final results = await conn.execute(
      Sql.named('''
        UPDATE users
        SET
          first_name = COALESCE(@firstName, first_name),
          last_name  = COALESCE(@lastName, last_name),
          updated_at = NOW()
        WHERE id = @id AND is_active = TRUE
        RETURNING *
      '''),
      parameters: {'id': id, 'firstName': firstName, 'lastName': lastName},
    );
    if (results.isEmpty) return null;
    return User.fromRow(results.first.toColumnMap());
  }

  Future<bool> delete(String id) async {
    final conn = await _conn;
    final results = await conn.execute(
      Sql.named('''
        UPDATE users SET is_active = FALSE, updated_at = NOW()
        WHERE id = @id AND is_active = TRUE
        RETURNING id
      '''),
      parameters: {'id': id},
    );
    return results.isNotEmpty;
  }
}

Profile Repository

Create lib/repositories/profile_repository.dart:

import 'package:postgres/postgres.dart';
import '../config/database.dart';
import '../models/profile.dart';

class ProfileRepository {
  Future<Connection> get _conn => Database.connection;

  Future<Profile?> findByUserId(String userId) async {
    final conn = await _conn;
    final results = await conn.execute(
      Sql.named('SELECT * FROM profiles WHERE user_id = @userId'),
      parameters: {'userId': userId},
    );
    if (results.isEmpty) return null;
    return Profile.fromRow(results.first.toColumnMap());
  }

  Future<Profile> create({
    required String userId,
    String? bio,
    String? avatarUrl,
    String? phone,
    String? location,
    String? website,
  }) async {
    final conn = await _conn;
    final results = await conn.execute(
      Sql.named('''
        INSERT INTO profiles (user_id, bio, avatar_url, phone, location, website)
        VALUES (@userId, @bio, @avatarUrl, @phone, @location, @website)
        RETURNING *
      '''),
      parameters: {
        'userId': userId,
        'bio': bio,
        'avatarUrl': avatarUrl,
        'phone': phone,
        'location': location,
        'website': website,
      },
    );
    return Profile.fromRow(results.first.toColumnMap());
  }

  Future<Profile?> update({
    required String userId,
    String? bio,
    String? avatarUrl,
    String? phone,
    String? location,
    String? website,
  }) async {
    final conn = await _conn;
    final results = await conn.execute(
      Sql.named('''
        UPDATE profiles
        SET
          bio        = COALESCE(@bio, bio),
          avatar_url = COALESCE(@avatarUrl, avatar_url),
          phone      = COALESCE(@phone, phone),
          location   = COALESCE(@location, location),
          website    = COALESCE(@website, website),
          updated_at = NOW()
        WHERE user_id = @userId
        RETURNING *
      '''),
      parameters: {
        'userId': userId,
        'bio': bio,
        'avatarUrl': avatarUrl,
        'phone': phone,
        'location': location,
        'website': website,
      },
    );
    if (results.isEmpty) return null;
    return Profile.fromRow(results.first.toColumnMap());
  }
}

Authentication Service

Authentication in this project is handled by a dedicated AuthService that lives in lib/services/. It has one clear responsibility: the cryptographic operations that power auth: hashing passwords before storing them, verifying passwords at login, generating signed JWT tokens on success, and verifying those tokens on protected requests.

Keeping this logic in a service rather than spreading it across route handlers means it can be injected via middleware and consumed cleanly anywhere in the app.

Create lib/services/auth_service.dart:

import 'package:bcrypt/bcrypt.dart';
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
import '../config/env.dart';
import '../models/user.dart';

class AuthService {
  String hashPassword(String password) =>
      BCrypt.hashpw(password, BCrypt.gensalt());

  bool verifyPassword(String password, String hash) =>
      BCrypt.checkpw(password, hash);

  String generateToken(User user) {
    final jwt = JWT({
      'sub': user.id,
      'email': user.email,
      'iat': DateTime.now().millisecondsSinceEpoch ~/ 1000,
    });
    return jwt.sign(
      SecretKey(Env.jwtSecret),
      expiresIn: Duration(hours: Env.jwtExpiryHours),
    );
  }

  JWT? verifyToken(String token) {
    try {
      return JWT.verify(token, SecretKey(Env.jwtSecret));
    } catch (_) {
      return null;
    }
  }
}

Middleware

Middleware is where Dart Frog's dependency injection model does its most important work. Rather than instantiating repositories and services inside each route handler, we create them once in middleware and make them available to every handler downstream via the RequestContext.

This section defines three pieces of middleware: the database middleware that injects the repositories and auth service, the auth middleware that validates JWT tokens and protects routes, and the error middleware that catches unhandled exceptions and returns consistent error responses across the entire API.

Database Middleware

Create lib/middleware/database_middleware.dart:

import 'package:dart_frog/dart_frog.dart';
import '../repositories/user_repository.dart';
import '../repositories/profile_repository.dart';
import '../services/auth_service.dart';

Middleware databaseMiddleware() {
  return (handler) {
    return handler
        .use(provider<UserRepository>((_) => UserRepository()))
        .use(provider<ProfileRepository>((_) => ProfileRepository()))
        .use(provider<AuthService>((_) => AuthService()));
  };
}

This middleware injects the repositories and auth service into every request context. Routes read them with context.read() without caring how they were created.

Auth Middleware

Create lib/middleware/auth_middleware.dart:

import 'dart:convert';
import 'package:dart_frog/dart_frog.dart';
import '../services/auth_service.dart';

Middleware authMiddleware() {
  return (handler) {
    return (context) async {
      final authHeader = context.request.headers['authorization'];

      if (authHeader == null || !authHeader.startsWith('Bearer ')) {
        return Response.json(
          statusCode: 401,
          body: {'error': 'Authorization header missing or malformed'},
        );
      }

      final token = authHeader.substring(7);
      final authService = context.read<AuthService>();
      final jwt = authService.verifyToken(token);

      if (jwt == null) {
        return Response.json(
          statusCode: 401,
          body: {'error': 'Invalid or expired token'},
        );
      }

      final userId = jwt.payload['sub'] as String;
      final userEmail = jwt.payload['email'] as String;

      return handler(
        context.provide<Map<String, String>>(
          () => {'userId': userId, 'userEmail': userEmail},
        ),
      );
    };
  };
}

Error Middleware

Create lib/middleware/error_middleware.dart:

import 'package:dart_frog/dart_frog.dart';

Middleware errorMiddleware() {
  return (handler) {
    return (context) async {
      try {
        return await handler(context);
      } on FormatException catch (e) {
        return Response.json(
          statusCode: 400,
          body: {'error': 'Invalid request body: ${e.message}'},
        );
      } catch (e, stackTrace) {
        print('Unhandled error: \(e\n\)stackTrace');
        return Response.json(
          statusCode: 500,
          body: {'error': 'An internal server error occurred'},
        );
      }
    };
  };
}

Building the Routes

With the models, repositories, auth service, and middleware all in place, we can now build the route handlers.

In Dart Frog, each file in the routes/ folder is a self-contained endpoint. Routes don't manage dependencies directly. Instead, they read what middleware has already injected into the context and call the appropriate repository or service method.

This section covers three groups of routes: the auth routes for registration and login, the user routes for CRUD operations, and the profile routes nested under a user's ID.

Auth Routes

Create routes/auth/register.dart:

import 'package:dart_frog/dart_frog.dart';
import '../../lib/repositories/user_repository.dart';
import '../../lib/services/auth_service.dart';

Future<Response> onRequest(RequestContext context) async {
  if (context.request.method != HttpMethod.post) {
    return Response.json(statusCode: 405, body: {'error': 'Method not allowed'});
  }

  final body = await context.request.json() as Map<String, dynamic>;
  final email = body['email'] as String?;
  final password = body['password'] as String?;
  final firstName = body['firstName'] as String?;
  final lastName = body['lastName'] as String?;

  if (email == null || password == null ||
      firstName == null || lastName == null) {
    return Response.json(
      statusCode: 400,
      body: {'error': 'email, password, firstName, and lastName are required'},
    );
  }

  if (password.length < 8) {
    return Response.json(
      statusCode: 400,
      body: {'error': 'Password must be at least 8 characters'},
    );
  }

  final userRepo = context.read<UserRepository>();
  final authService = context.read<AuthService>();

  final existing = await userRepo.findByEmail(email);
  if (existing != null) {
    return Response.json(
      statusCode: 409,
      body: {'error': 'An account with this email already exists'},
    );
  }

  final user = await userRepo.create(
    email: email,
    passwordHash: authService.hashPassword(password),
    firstName: firstName,
    lastName: lastName,
  );

  return Response.json(
    statusCode: 201,
    body: {
      'user': user.toJson(),
      'token': authService.generateToken(user),
    },
  );
}

Create routes/auth/login.dart:

import 'package:dart_frog/dart_frog.dart';
import '../../lib/repositories/user_repository.dart';
import '../../lib/services/auth_service.dart';

Future<Response> onRequest(RequestContext context) async {
  if (context.request.method != HttpMethod.post) {
    return Response.json(statusCode: 405, body: {'error': 'Method not allowed'});
  }

  final body = await context.request.json() as Map<String, dynamic>;
  final email = body['email'] as String?;
  final password = body['password'] as String?;

  if (email == null || password == null) {
    return Response.json(
      statusCode: 400,
      body: {'error': 'email and password are required'},
    );
  }

  final userRepo = context.read<UserRepository>();
  final authService = context.read<AuthService>();
  final user = await userRepo.findByEmail(email);

  if (user == null || !authService.verifyPassword(password, user.passwordHash)) {
    return Response.json(
      statusCode: 401,
      body: {'error': 'Invalid email or password'},
    );
  }

  return Response.json(
    body: {
      'user': user.toJson(),
      'token': authService.generateToken(user),
    },
  );
}

User Routes

Create routes/users/index.dart:

import 'package:dart_frog/dart_frog.dart';
import '../../lib/repositories/user_repository.dart';

Future<Response> onRequest(RequestContext context) async {
  if (context.request.method != HttpMethod.get) {
    return Response.json(statusCode: 405, body: {'error': 'Method not allowed'});
  }

  final userRepo = context.read<UserRepository>();
  final users = await userRepo.findAll();

  return Response.json(
    body: users.map((u) => u.toJson()).toList(),
  );
}

Create routes/users/[id].dart:

import 'package:dart_frog/dart_frog.dart';
import '../../lib/repositories/user_repository.dart';

Future<Response> onRequest(RequestContext context, String id) async {
  final userRepo = context.read<UserRepository>();

  switch (context.request.method) {
    case HttpMethod.get:
      return _getUser(userRepo, id);
    case HttpMethod.put:
      return _updateUser(context, userRepo, id);
    case HttpMethod.delete:
      return _deleteUser(userRepo, id);
    default:
      return Response.json(
        statusCode: 405,
        body: {'error': 'Method not allowed'},
      );
  }
}

Future<Response> _getUser(UserRepository repo, String id) async {
  final user = await repo.findById(id);
  if (user == null) {
    return Response.json(statusCode: 404, body: {'error': 'User not found'});
  }
  return Response.json(body: user.toJson());
}

Future<Response> _updateUser(
  RequestContext context,
  UserRepository repo,
  String id,
) async {
  final body = await context.request.json() as Map<String, dynamic>;
  final user = await repo.update(
    id: id,
    firstName: body['firstName'] as String?,
    lastName: body['lastName'] as String?,
  );
  if (user == null) {
    return Response.json(statusCode: 404, body: {'error': 'User not found'});
  }
  return Response.json(body: user.toJson());
}

Future<Response> _deleteUser(UserRepository repo, String id) async {
  final deleted = await repo.delete(id);
  if (!deleted) {
    return Response.json(statusCode: 404, body: {'error': 'User not found'});
  }
  return Response.json(statusCode: 204, body: null);
}

Notice how onRequest receives String id as a second parameter, Dart Frog automatically passes the dynamic path segment to the handler. The switch on context.request.method handles all HTTP methods in a single file which is the idiomatic Dart Frog pattern for CRUD endpoints.

Profile Routes

Create routes/users/[id]/profile.dart:

import 'package:dart_frog/dart_frog.dart';
import '../../../lib/repositories/user_repository.dart';
import '../../../lib/repositories/profile_repository.dart';

Future<Response> onRequest(RequestContext context, String id) async {
  final userRepo = context.read<UserRepository>();
  final profileRepo = context.read<ProfileRepository>();

  final user = await userRepo.findById(id);
  if (user == null) {
    return Response.json(statusCode: 404, body: {'error': 'User not found'});
  }

  switch (context.request.method) {
    case HttpMethod.get:
      return _getProfile(profileRepo, id);
    case HttpMethod.post:
      return _createProfile(context, profileRepo, id);
    case HttpMethod.put:
      return _updateProfile(context, profileRepo, id);
    default:
      return Response.json(
        statusCode: 405,
        body: {'error': 'Method not allowed'},
      );
  }
}

Future<Response> _getProfile(ProfileRepository repo, String userId) async {
  final profile = await repo.findByUserId(userId);
  if (profile == null) {
    return Response.json(statusCode: 404, body: {'error': 'Profile not found'});
  }
  return Response.json(body: profile.toJson());
}

Future<Response> _createProfile(
  RequestContext context,
  ProfileRepository repo,
  String userId,
) async {
  final existing = await repo.findByUserId(userId);
  if (existing != null) {
    return Response.json(
      statusCode: 409,
      body: {'error': 'Profile already exists for this user'},
    );
  }

  final body = await context.request.json() as Map<String, dynamic>;
  final profile = await repo.create(
    userId: userId,
    bio: body['bio'] as String?,
    avatarUrl: body['avatarUrl'] as String?,
    phone: body['phone'] as String?,
    location: body['location'] as String?,
    website: body['website'] as String?,
  );
  return Response.json(statusCode: 201, body: profile.toJson());
}

Future<Response> _updateProfile(
  RequestContext context,
  ProfileRepository repo,
  String userId,
) async {
  final body = await context.request.json() as Map<String, dynamic>;
  final profile = await repo.update(
    userId: userId,
    bio: body['bio'] as String?,
    avatarUrl: body['avatarUrl'] as String?,
    phone: body['phone'] as String?,
    location: body['location'] as String?,
    website: body['website'] as String?,
  );
  if (profile == null) {
    return Response.json(statusCode: 404, body: {'error': 'Profile not found'});
  }
  return Response.json(body: profile.toJson());
}

Wiring the Middleware Pipeline

The routes and middleware are all written, but they aren't connected yet. In Dart Frog, the connection happens through _middleware.dart files placed strategically in the routes/ folder.

To review, a _middleware.dart file at the root level applies to every route in the project. A _middleware.dart inside a subfolder applies only to routes in that folder and below. This gives us precise, folder-scoped control over which middleware runs where without any manual registration or mounting.

Create routes/_middleware.dart for global middleware applied to every route:

import 'package:dart_frog/dart_frog.dart';
import '../lib/middleware/database_middleware.dart';
import '../lib/middleware/error_middleware.dart';

Handler middleware(Handler handler) {
  return handler
      .use(databaseMiddleware())
      .use(errorMiddleware());
}

Create routes/users/_middleware.dart to protect all user routes with authentication:

import 'package:dart_frog/dart_frog.dart';
import '../../lib/middleware/auth_middleware.dart';

Handler middleware(Handler handler) {
  return handler.use(authMiddleware());
}

This is one of the most elegant parts of Dart Frog's model. The routes/users/_middleware.dart file automatically applies auth to every route under routes/users/, including routes/users/index.dart, routes/users/[id].dart, and routes/users/[id]/profile.dart. The auth routes under routes/auth/ are untouched because they live outside the users/ folder.

There's no manual middleware mounting, no array of protected routes, and no route group configuration. The folder structure does the work.

Testing the API

With the server running and all routes wired up, we can verify the full flow end to end. Start the development server and run through each endpoint in order: register a user first to get a token, then use that token on the protected routes. Replace {userId} in the commands below with the actual ID returned from the register response.

Start the development server:

dart_frog dev
# Server is now running at: http://localhost:8080

Register a user:

curl http://localhost:8080/auth/register \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{
    "email": "seyi@example.com",
    "password": "securepassword",
    "firstName": "Seyi",
    "lastName": "Dev"
  }'

Response:

{
  "user": {
    "id": "uuid-here",
    "email": "seyi@example.com",
    "firstName": "Seyi",
    "lastName": "Dev",
    "isActive": true,
    "createdAt": "2025-01-01T00:00:00.000Z",
    "updatedAt": "2025-01-01T00:00:00.000Z"
  },
  "token": "eyJhbGci..."
}

Login:

curl http://localhost:8080/auth/login \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{"email": "seyi@example.com", "password": "securepassword"}'

Get all users:

curl http://localhost:8080/users \
  -H "Authorization: Bearer eyJhbGci..."

Get a specific user:

curl http://localhost:8080/users/{userId} \
  -H "Authorization: Bearer eyJhbGci..."

Create a profile:

curl http://localhost:8080/users/{userId}/profile \
  -X POST \
  -H "Authorization: Bearer eyJhbGci..." \
  -H "Content-Type: application/json" \
  -d '{
    "bio": "Flutter engineer turned backend developer",
    "location": "Lagos, Nigeria",
    "website": "https://example.com"
  }'

Update a user:

curl http://localhost:8080/users/{userId} \
  -X PUT \
  -H "Authorization: Bearer eyJhbGci..." \
  -H "Content-Type: application/json" \
  -d '{"firstName": "Oluwaseyi"}'

Delete a user:

curl http://localhost:8080/users/{userId} \
  -X DELETE \
  -H "Authorization: Bearer eyJhbGci..."

Deployment

With everything tested locally, the final step is getting the API live. Dart Frog makes this straightforward: a single CLI command generates a production-ready Dockerfile, and from there we deploy to Fly.io where the app will run as a containerized service alongside a managed PostgreSQL database.

Production Build

Dart Frog generates a production-ready Docker setup with a single command:

dart_frog build

This creates a build/ directory containing:

build/
  bin/
    server.dart         ← compiled entry point
  Dockerfile            ← production Dockerfile
  pubspec.yaml
  pubspec.lock

The generated Dockerfile is a multi-stage build, compiles to a native binary in the first stage, runs from a minimal Debian image in the second. You do not need to write this yourself.

Deploying to Fly.io

Step 1 — Authenticate:

fly auth login

Step 2 — Launch from the build directory:

cd build
fly launch

Fly detects the Dockerfile and prompts for configuration. Create a PostgreSQL database when asked.

Step 3 — Set secrets:

fly secrets set JWT_SECRET="your_production_jwt_secret"
fly secrets set JWT_EXPIRY_HOURS="24"

Step 4 — Deploy:

fly deploy

Step 5 — Verify:

curl https://your-app-name.fly.dev/auth/register \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"password123","firstName":"Seyi","lastName":"Dev"}'

Conclusion

Dart Frog sits exactly where it positions itself: between the raw control of Shelf and the full opinions of Serverpod. It takes the file-based routing model that has proven itself in the JavaScript ecosystem and brings it to Dart cleanly, without compromising on the language's strengths.

The routing model is its strongest feature. Looking at the routes/ folder tells you everything about your API: what endpoints exist, how they are grouped, and which middleware applies to which sections. That transparency makes codebases easier to navigate, easier to onboard into, and easier to reason about as they grow.

The RequestContext and the provider pattern for dependency injection are well thought out. Middleware injects, routes consume, and nothing bleeds between the two. The folder-scoped middleware is particularly clean, protecting an entire section of your API is as simple as dropping a _middleware.dart file in the right folder.

For Flutter engineers building APIs that need to serve multiple client types, conform to standard REST conventions, or integrate cleanly with existing frontend infrastructure, Dart Frog hits a practical sweet spot that neither Shelf nor Serverpod reaches as naturally.

Dart is now a full-stack language in the truest sense. The same team, the same language, the same conventions – from the Flutter app to the server that powers it.

Happy Coding!



Read the whole story
alvinashcraft
48 seconds ago
reply
Pennsylvania, USA
Share this story
Delete

Podcast: Craig McLuckie on Culture as a Team's Operating System in the AI Era

1 Share

In this podcast, Shane Hastie, Lead Editor for Culture & Methods spoke to Craig McLuckie, co-creator of Kubernetes and CEO of Stacklok, about the impact of AI coding tools on open source communities and engineering teams, designing deliberate organisational culture, and navigating evolving career paths for engineers in the age of AI.

By Craig McLuckie
Read the whole story
alvinashcraft
1 minute ago
reply
Pennsylvania, USA
Share this story
Delete

Safely injecting a JSON configuration object into a Razor Page

1 Share

While reviewing an ASP.NET Core Razor page application that needed to share server-side configuration with client-side JavaScript, I noticed the following approach to inject a JSON object:

<script>
    var featureFlags= @Html.Raw(Model.FeatureFlagsJson);
</script>

It works — until it doesn't. This post walks through the right way to do it, why the naive approach can blow up in your face, and what the production-safe pattern looks like.

Why the naive approach is dangerous

Directly interpolating server-side values into a <script> block creates an XSS (Cross-Site Scripting) vector. If any value in your config object contains characters like </script>, ", or ', the browser can interpret that as the end of your script tag — or worse, execute attacker-controlled code.

Consider this innocent-looking config value:

public string FeatureFlags{ get; set; } = "My App </script><script>alert('pwned')";

Inlined naively, that produces:

<script>
    var featureFlags= { appName: "My App </script><script>alert('pwned')" };
</script>

The browser sees the </script> as closing your block, and the injected script runs.

A simple and clean solution: <script type="application/json">

I flagged the issue in the PR and asked the developer to find a solution. She came up with a solution I wasn’t aware that it existed: using a <script> tag with a non-executable MIME type as a data container.

@page
@model IndexModel

<script type="application/json" id="featureflags-config">
    @Model.FeatureFlagsJson
</script>

Because the browser only executes <script> tags with a JavaScript MIME type (or no type at all), a type="application/json" block is treated as inert data — the content is never parsed as code. That means a </script> sequence inside your JSON cannot break out of the block or execute anything. The browser simply ignores it as a script.

You then read it in JavaScript with a single JSON.parse:

const configEl = document.getElementById('featureflags-config');
const config = JSON.parse(configEl.textContent);

if (config.featureFlags.newDashboard) {
    initNewDashboard();
}

What on the C# side?

On the C# side, we need to serialize our JSON in a more relaxed (I like the naming) way:

var options = new JsonSerializerOptions
{
    Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, // safe here — not in a script block
    WriteIndented = false
};

ClientConfigJson = JsonSerializer.Serialize(config, options);

And in the Razor view, standard Razor encoding is fine — no Html.Raw needed:

<script type="application/json" id="featureflags-config">@Model.ClientConfigJson</script>

Razor's default HTML encoding will turn " into &quot; and < into &lt; inside the tag content, which JSON.parse won't understand. To avoid this, we use UnsafeRelaxedJsonEscaping on the C# side (which produces unescaped quotes) and rely on the fact that the browser correctly reads the raw text content via textContent, not innerHTML. The textContent property gives you the decoded text, so &quot; becomes " before JSON.parse sees it.

What about a <meta> tag or a separate endpoint?

Two common alternatives worth knowing about:

<meta> tags work well for a single scalar value but are awkward for a structured object and require manual parsing in JavaScript. Not ideal for a config object.

A dedicated /config JSON endpoint is a clean approach for large or sensitive configs, at the cost of an extra HTTP round-trip before your app can initialize. If your config is large or access-controlled, this is worth considering. For lightweight, non-sensitive config that's available at page render time, the inline approach above is simpler and faster.

Checklist

  • Never interpolate raw C# strings directly into <script> blocks
  • If using an executable <script> block: serialize with JavaScriptEncoder.Default and use Html.Raw
  • If using <script type="application/json">: read via textContent + JSON.parse, not innerHTML
  • Keep secrets (API keys, connection strings) off the client config entirely — this is for UI configuration, not credentials If using the application/json approach, make sure your JS runs after the DOM element exists (defer or DOMContentLoaded)

The pattern is simple once you have it in place, and it closes a class of injection bugs that are easy to introduce and nasty to debug.

Read the whole story
alvinashcraft
1 minute ago
reply
Pennsylvania, USA
Share this story
Delete

Developers are emotionally attached to their tools​​​​‌‍​‍​‍‌‍‌​‍‌‍‍‌‌‍‌‌‍‍‌‌‍‍​‍​‍​‍‍​‍​‍‌​‌‍​‌‌‍‍‌‍‍‌‌‌​‌‍‌​‍‍‌‍‍‌‌‍​‍​‍​‍​​‍​‍‌‍‍​‌​‍‌‍‌‌‌‍‌‍​‍​‍​‍‍​‍​‍‌‍‍​‌‌​‌‌​‌​​‌​​‍‍​‍​‍‌‍​‌‍‌‌​​‍‍‌​‌‌​‌‍​‌‌‍​‌‍‍‌‍‌‌‍‌‍‌‌‌​‍‌‍‌‍‌‍​‌‍‌‌​‍‍‌‍​‌‍​‍‌‍‍‌‌‍‍‌‌​‌‍‌‌‌‍‍‌‌​​‍‌‍‌‌‌‍‌​‌‍‍‌‌‌​​‍‌‍‌‌‍‌‍‌​‌‍‌‌​‌‌​​‌​‍‌‍‌‌‌​‌‍‌‌‌‍‍‌‌​‌‍​‌‌‌​‌‍‍‌‌‍‌‍‍​‍‌‍‍‌‌‍‌​​‌‌‍‌‌‌‍‌‌‌‍‌​​​‍‌‍​‍​‌​​‌‍‌‍‌‍​‍‌​​​‌​‌‍​‍​‍​​‍‌​‌​‌‍‌‍‌‍​‌‍​‍​‍‌‌‍​‍​‌‌​‌‌​​‍​‍‌​​‍‌‍​‍‌‍​‌‍​​​‍​‌‌‌‍​​​‌‌‍​‌‍‌‌​‌​​‌​​‍‌‌​‌‍‌‌​​‌‍‌‌​‌‌‍​‍‌‍​‌‍‌‍‌‌‌​​‌‍‌​‌‌​​‍‌​​‌‍​‌‌‌​‌‍‍​​‌‌‌​‌‍‍‌‌‌​‌‍​‌‍‌‌​‌‍​‍‌‍​‌‌​‌‍‌‌‌‌‌‌‌​‍‌‍​​‌‌‍‍​‌‌​‌‌​‌​​‌​​‍‌‌​​‌​​‌​‍‌‌​​‍‌​‌‍​‍‌‌​​‍‌​‌‍‌‍​‌‍‌‌​​‍‍‌​‌‌​‌‍​‌‌‍​‌‍‍‌‍‌‌‍‌‍‌‌‌​‍‌‍‌‍‌‍​‌‍‌‌​‍‍‌‍​‌‍​‍‌‍‌‍‍‌‌‍‌​​‌‌‍‌‌‌‍‌‌‌‍‌​​​‍‌‍​‍​‌​​‌‍‌‍‌‍​‍‌​​​‌​‌‍​‍​‍​​‍‌​‌​‌‍‌‍‌‍​‌‍​‍​‍‌‌‍​‍​‌‌​‌‌​​‍​‍‌​​‍‌‍​‍‌‍​‌‍​​​‍​‌‌‌‍​​​‌‌‍​‌‍‌‌​‌​​‌​​‍‌‍‌‌​‌‍‌‌​​‌‍‌‌​‌‌‍

1 Share
Ryan welcomes Trisha Gee, a Java champion and developer productivity advocate, to explore how AI is transforming the role of IDEs and the broader developer experience; the relevance of traditional tools, muscle memory, the risks of hype; and how to adapt workflows for AI-driven development.​​​​‌‍​‍​‍‌‍‌​‍‌‍‍‌‌‍‌‌‍‍‌‌‍‍​‍​‍​‍‍​‍​‍‌​‌‍​‌‌‍‍‌‍‍‌‌‌​‌‍‌​‍‍‌‍‍‌‌‍​‍​‍​‍​​‍​‍‌‍‍​‌​‍‌‍‌‌‌‍‌‍​‍​‍​‍‍​‍​‍‌‍‍​‌‌​‌‌​‌​​‌​​‍‍​‍​‍‌‍​‌‍‌‌​​‍‍‌​‌‌​‌‍​‌‌‍​‌‍‍‌‍‌‌‍‌‍‌‌‌​‍‌‍‌‍‌‍​‌‍‌‌​‍‍‌‍​‌‍​‍‌‍‍‌‌‍‍‌‌​‌‍‌‌‌‍‍‌‌​​‍‌‍‌‌‌‍‌​‌‍‍‌‌‌​​‍‌‍‌‌‍‌‍‌​‌‍‌‌​‌‌​​‌​‍‌‍‌‌‌​‌‍‌‌‌‍‍‌‌​‌‍​‌‌‌​‌‍‍‌‌‍‌‍‍​‍‌‍‍‌‌‍‌​​‌‌‍‌‌‌‍‌‌‌‍‌​​​‍‌‍​‍​‌​​‌‍‌‍‌‍​‍‌​​​‌​‌‍​‍​‍​​‍‌​‌​‌‍‌‍‌‍​‌‍​‍​‍‌‌‍​‍​‌‌​‌‌​​‍​‍‌​​‍‌‍​‍‌‍​‌‍​​​‍​‌‌‌‍​​​‌‌‍​‌‍‌‌​‌​​‌​​‍‌‌​‌‍‌‌​​‌‍‌‌​‌‌‍​‍‌‍​‌‍‌‍‌‌‌​​‌‍‌​‌‌​​‍‌​​‌‍​‌‌‌​‌‍‍​​‌‌‍‌‌‌‍​‌‍​‌‍‌‌‌​‍‌​​‌‌​​‌‍​‍‌‍​‌‌​‌‍‌‌‌‌‌‌‌​‍‌‍​​‌‌‍‍​‌‌​‌‌​‌​​‌​​‍‌‌​​‌​​‌​‍‌‌​​‍‌​‌‍​‍‌‌​​‍‌​‌‍‌‍​‌‍‌‌​​‍‍‌​‌‌​‌‍​‌‌‍​‌‍‍‌‍‌‌‍‌‍‌‌‌​‍‌‍‌‍‌‍​‌‍‌‌​‍‍‌‍​‌‍​‍‌‍‌‍‍‌‌‍‌​​‌‌‍‌‌‌‍‌‌‌‍‌​​​‍‌‍​‍​‌​​‌‍‌‍‌‍​‍‌​​​‌​‌‍​‍​‍​​‍‌​‌​‌‍‌‍‌‍​‌‍​‍​‍‌‌‍​‍​‌‌​‌‌​​‍​‍‌​​‍‌‍​‍‌‍​‌‍​​​‍​‌‌‌‍​​​‌‌‍​‌‍‌‌​‌​​‌​​‍‌‍‌‌​‌‍‌‌​​‌‍‌‌​‌‌‍​‍‌‍​‌‍‌‍‌‌‌​​‌‍‌​‌‌​​‍‌‍‌​​‌‍​‌‌‌​‌‍‍​​‌‌‍‌‌‌‍​‌‍​‌‍‌‌‌​‍‌​​‌‌​​‍‌‍‌​​‌‍‌‌‌​‍‌​‌​​‌‍‌‌‌‍​‌‌​‌‍‍‌‌‌‍‌‍‌‌​‌‌​​‌‌‌‌‍​‍‌‍​‌‍‍‌‌​‌‍‍​‌‍‌‌‌‍‌​​‍​‍‌‌
Read the whole story
alvinashcraft
5 hours ago
reply
Pennsylvania, USA
Share this story
Delete
Next Page of Stories