DEV Community

Cover image for 4 Lessons I Learned Building a C++20 Web Framework
Asaf Dahan
Asaf Dahan

Posted on Originally published at Medium AI-assisted

4 Lessons I Learned Building a C++20 Web Framework

My first C++ projects involved sockets and a thread-per-client model. Later, I got seriously into web development, tried several languages and frameworks, and eventually found ASP.NET Core. I loved it. To this day, it’s my favorite web framework, and it set the standard for how I wanted building an API to feel. That made the boilerplate I encountered in C++ web development especially frustrating. I just wanted to write a simple endpoint, why did getting there have to involve so much low-level setup? I enjoyed C++, and I wanted that same convenience when working in it. The more I thought about it, the more I wanted to build a framework I would enjoy using myself. That became Mach.

About six months later, I was writing examples for Mach when I stopped to appreciate the simplest one:

#include <mach/mach.hpp>

int main() {
    mach::AppBuilder builder;
    auto app = builder.build();

    app.mapGet("/", [] {
        return mach::ok("Hello, World!");
    });

    return app.run();
}
Enter fullscreen mode Exit fullscreen mode

It was roughly twelve lines of C++ that defined an endpoint and started a web server. It wasn’t the longest or most technically impressive example, but it was the one that made me the happiest. This was the experience I had imagined at the beginning, and now I could actually use it.

Getting there taught me a lot about API design, abstraction, testing, and deciding what belonged in the first release. Some lessons came from features that worked as I’d hoped. Others came from code I was convinced was correct, until the tests failed.

1. Making a simple API takes deliberate work

One thing I cared about was how many rules someone would have to remember just to write a handler.

If an endpoint needed both a request body and a context object, which parameter should come first? I didn’t think there was an obvious answer. I remembered being frustrated by tools that required a particular order, and I didn’t want to introduce that same friction into Mach.

So I supported minimal API handlers with no parameters, just the body or context, or both in either order.

app.mapPost("/users", [] {
    // no parameters
});

app.mapPost("/users", [](CreateUser body) {
    // body only
});

app.mapPost("/users", [](mach::Context& context) {
    // context only
});

app.mapPost("/users", [](CreateUser body, mach::Context& context) {
    // body + context
});

app.mapPost("/users", [](mach::Context& context, CreateUser body) {
    // context + body
});
Enter fullscreen mode Exit fullscreen mode

That looks like a small convenience from the outside. Internally, Mach has to inspect the signature, identify what each parameter represents, reject unsupported combinations, and arrange the invocation correctly. The signature analysis happens at compile time; binding an incoming body and calling the handler happen at runtime.

It took work, but I thought saving users that documentation lookup and frustration was worth it. Supporting both orders didn’t necessarily make an endpoint shorter. It removed something developers would otherwise have to remember.

Validation involved a similar decision about how much work should belong to the framework.

Coming from ASP.NET Core, I initially had annotation-based validation in mind. I couldn’t simply reproduce that reflection-driven approach with the C++ tools I was using. I considered providing a static collection of utilities such as range(), equals(), and email(). Developers would use them to write their own validation logic, handle failed checks, and prepare errors.

That would have been useful, but it still left them assembling the surrounding workflow. I eventually arrived at two builders: ValidationBuilder for the object, and FieldValidationBuilder for individual fields. Developers declare their rules in a validate() method:

struct RegisterRequest {
    std::string username;
    std::string email;
    int age;

void validate(mach::ValidationBuilder<RegisterRequest>& validation) const {
        validation.field(&RegisterRequest::username)
            .minLength(3)
            .maxLength(30);

        validation.field(&RegisterRequest::email).email();
        validation.field(&RegisterRequest::age).min(18);
    }
};

MACH_DEFINE_JSON(RegisterRequest, username, email, age)
Enter fullscreen mode Exit fullscreen mode

Pointers to members were already part of how I registered controller actions, so using them to identify fields felt familiar. The developer chooses the rules. Mach validates the request body and, if validation fails, returns a 400 response with a list of error messages.

I like to call Mach semi-automatic because of that division of responsibility: users describe what they want, and the framework handles the repetitive work of making it happen.

I also became comfortable borrowing designs I already liked. Minimal APIs, controllers, dependency injection, and middleware stayed close to the ASP.NET Core style. Validation needed a different approach. I wanted familiar, convenient features, and I was happy to reuse an established pattern when it served that goal.

2. Learning where abstraction belongs

Before Mach, I had used third-party libraries and split code into headers and source files. Building a framework made me think much more carefully about the boundary between its public interface and its implementation.

A person writing those twelve lines doesn’t need to know how requests are parsed, adapted, routed, or passed to a handler. Making that possible involved deliberate choices about public headers, private source files, build configuration, and which classes could depend on which others.

One technique I learned was PImpl (Pointer to Implementation). AppBuilder, the class responsible for configuring and building a Mach application, began as a regular class and later used a separately defined implementation behind a pointer. That let me hide details that didn't belong in the interface application developers worked with.

I also learned to be selective about it.

In the form I used, constructing the implementation behind a unique_ptr introduces an extra heap allocation. I was comfortable with that during application setup. For an object created on every request, such as Request, I was much more reluctant to add an allocation purely to hide its implementation.

That was a design consideration rather than a benchmark result. The same technique could be useful in one part of Mach and an unnecessary cost in another. Learning PImpl also meant learning where to stop applying it.

Ownership was another area I had to learn properly. At the beginning, smart pointers were unfamiliar, and getting object lifetimes wrong worried me more than it probably should have. Asynchronous HTTP operations made the question especially concrete: the data an operation needs must still exist when that operation resumes.

I learned to distinguish between storing a value, borrowing through a reference or non-owning pointer, transferring ownership, and sharing it. I used unique_ptr heavily and shared_ptr in places where shared lifetime was useful, including asynchronous operations. Move semantics became comfortable surprisingly quickly.

There were lifetime bugs, but ownership ended up being one of the more manageable parts of the project. I finished Mach without writing a single explicit new or delete, something I had never done before. I was still responsible for lifetimes; RAII and smart pointers gave me better ways to express that responsibility.

The boundary around the project itself mattered too. I wanted to spend my effort on Mach’s architecture and behavior. I used Boost.Asio and Boost.Beast for asynchronous networking and HTTP, and spdlog for logging. Building those underlying systems myself would have meant taking on substantial projects of their own.

3. Learning to challenge code I trusted

Routing was probably the largest single component of Mach. It started as a simple hash map matching paths, then grew into a trie as the requirements expanded. I added handlers, the distinction between 404 and 405, parameters, precedence rules, and parameter extraction.

At some point, I felt confident it worked. I ran the routing tests partly to tick the box.

Then some of them failed.

Investigating those failures exposed a missing piece of the traversal: backtracking. I don’t remember the exact original paths, but this reconstructs the situation. Imagine two routes registered for the same HTTP method:

/a/{param}/c
/a/b/d
Now a request arrives for /a/b/c.

The router prefers static segments over parameterized ones, so after /a, it follows the literal b. But that branch only has d beneath it. The original loop-based traversal reached that dead end and returned 404.

The request should have matched /a/{param}/c, with param set to b.

My implementation had committed to a locally preferred branch without checking whether it could produce a complete match. I changed the traversal to a recursive approach that could backtrack: try the static branch first, and if it cannot complete the match, try the parameterized branch.

Recursion was how I solved the problem; the important missing behavior was the ability to reconsider an earlier choice.

After that, I looked more deliberately for ambiguities. Some should resolve through precedence and backtracking. Others should be rejected, such as registering both /a/{name}/c and /a/{id}/c for the same method. Changing a parameter’s name doesn’t make those different matching patterns.

The failure changed how seriously I took tests. I began asking what combinations might break a system I thought was working. When I asked ChatGPT to help write tests, I explicitly asked it to try to break the implementation and explore particular edge cases.

I still reviewed the cases and expected results. Later in development, I sometimes investigated a failure and found a bug in the test itself. That didn’t undo the routing lesson. It reminded me that the test was also code I needed to understand.

I became more willing to question my confidence and more careful about deciding what a failure actually meant.

4. Shipping means accepting trade-offs

Mach was fairly well planned from the beginning. I had a feature list from April, and I stayed close to it. Some things were added, some removed, and some moved to later versions.

One feature I wanted was broader typed request binding. Mach v1.0 binds request bodies to handler parameters. I also wanted developers to receive typed route and query parameters directly in a handler’s signature. For example, I wanted an endpoint like /users/42?sort=name to be expressible as:

app.mapGet("/users/{id}", [](int id, std::string sort) {
    // ...
});
Enter fullscreen mode Exit fullscreen mode

I believed it would improve the developer experience. But identifying where each parameter should come from, validating the combinations at compile time, and integrating that into the existing machinery needed more design and implementation work.

As v1.0 and the API freeze approached, I became less willing to reopen core parts of the framework for a major addition.

I postponed it.

There wasn’t a dramatic conclusion that the feature was impossible. I had a possible design, and I still wanted to pursue it. I simply didn’t want to force that work into the final stretch before release.

Once v1.0 was out, I could reconsider the feature with room to implement and test it properly. Deferring it gave me a way to finish the release while keeping the idea alive.

I learned to include timing in my design decisions. A feature could be valuable and achievable while still belonging in a later version.

Writing Mach’s examples gave me a chance to experience the result of all those decisions. I could see whether the code felt tiring to write, whether I was repeating boilerplate, and whether the API felt natural in practice.

I enjoyed it. That mattered to me because enjoying C++ web development was the reason I had started.

The smallest example remained my favorite. I knew what was underneath those twelve lines: the routing decisions, lifetime management, handler machinery, and tests that had caught things I missed.

I had imagined being able to write an endpoint that way months earlier. By the end, I could. Along the way, I’d learned much more about building software for someone else to use.

Mach is available at machframework.dev, with documentation and links to the source. If you try it, I’d love to hear which parts feel intuitive, and where you still find yourself fighting the framework.

Top comments (0)