DEV Community

Cover image for I got tired of Express and built something different
Matthieu Rondeau
Matthieu Rondeau

Posted on

I got tired of Express and built something different

I have been writing Express APIs for a few years and at some point it just stopped being fun. Not one big thing, more like a hundred small frustrations that pile up over time.

Every new project starts the same. You spend the first day wiring the same stuff before writing a single line of actual business logic. Auth middleware, validation, error handling, request parsing, CORS, logging. None of it is hard, all of it is boring, and you do it again every single time.

Then the project grows and everything gets worse. Validation duplicated across handlers. Auth middleware you have to manually add to every single route, which means the one time you forget it something embarrassing goes to production. Endpoints that become 200 lines because everything ends up in one place. TypeScript types that just stop at the controller level, you validate the body, cast it to what you think it is, and hope no one changed something since last time.

I looked at NestJS. Too much ceremony for what I wanted. Decorators, modules, providers, a whole dependency injection system to understand before you can do anything useful. Felt like learning a framework inside a framework.

So I just built something.

The idea

Instead of routes, you declare capabilities. A capability is a typed function that says what your server can do. The input schema is Zod, that same schema drives the TypeScript types, the runtime validation, everything. You write the shape once.

const createPost = capability(
  z.object({
    title: z.string().min(1),
    body:  z.string().min(1),
  }),
  async ({ title, body }, ctx) => {
    return ctx.db.posts.create({
      authorId: ctx.user.id,
      title,
      body,
    });
  },
  'mutation'
).guard(mustBeUser);
Enter fullscreen mode Exit fullscreen mode

No req. No res. No next. title and body are strings inside the resolver, not unknown. The guard runs before the resolver every time, not sometimes, every time. If something throws it becomes an error response. You return a value, that is your response.

The REST transport reads this and infers POST /posts from the name and intent. You do not configure the route.

What changed in practice

Testing became much simpler. A capability is just a function, you call it directly, no server, no HTTP, no port to manage, no cleanup.

const ctx    = mockContext({ user: { id: '1' } });
const result = await createPost.resolve({ title: 'Hello', body: 'World' }, ctx);
expect(result.title).toBe('Hello');
Enter fullscreen mode Exit fullscreen mode

Auth is now impossible to forget. In Express you add a route and maybe you remember the middleware. In Capix the guard is part of the capability. There is no way to call it without the guard running.

The start of a new project is just different now. capix new my-api and you have context, guards, error handling already there. First thing you write is actual business logic.

The transport thing

This part I did not fully expect when I started. Because a capability does not know how it is being called, the same definition works over REST, GraphQL, WebSocket, job queue.

createServer({
  context:      buildContext,
  capabilities: {
    api:  { posts: { createPost, listPosts, getPost } },
    jobs: { emails: { sendWelcome } },
  },
  transports: [
    restTransport({ port: 3000 }),
    graphqlTransport({ port: 4000 }),
    queueTransport({ queues: ['emails'], adapter }),
  ],
});
Enter fullscreen mode Exit fullscreen mode

GraphQL schema generates from the Zod schemas automatically. Queue transport invokes capabilities directly with the same validation and error handling as HTTP. In practice most APIs have sync endpoints and background jobs doing related things, having them share the same definitions means they cannot drift apart over time.

The parts that are not perfect

TypeScript narrowing has a known limitation. Guards narrow the context type but TypeScript cannot express this cleanly across capability composition without a workaround. There is a two-factory pattern documented that handles it but its still a workaround, not a real fix.

It is alpha. API is mostly stable but there are rough edges.

Performance is fine, faster than Express by around 65%, faster than Hono, within 3% of Fastify on auth scenarios. The gap with Fastify on validation heavy routes is because they use a compiled JSON serializer with JSON Schema, Capix uses Zod for end to end TypeScript types. That is a choice not a bug.

Try it

npx @capixjs/cli@alpha new my-api
cd my-api
pnpm install
pnpm dev
Enter fullscreen mode Exit fullscreen mode

Docs: https://milanito.github.io/capix/

GitHub: https://github.com/milanito/capix

Alpha means bug reports are more useful than stars. If something breaks open an issue.

Top comments (0)