---
title: "Scala Backend Template: Madrileño Ships the First Six Weeks"
url: "https://www.iteratorshq.com/blog/scala-backend-template-madrileno/"
type: article
description: "madrileño is an open-source Scala backend template: auth, jobs, outbox, feature flags and tracing as code you own, plus typed web and mobile clients."
published: 2026-10-28
updated: 2026-10-30
author: "Katarzyna Kozłowska"
site: Iterators
categories: [Tech Blog]
tags: [Developer Productivity, Software Engineering]
---

# Scala Backend Template: Madrileño Ships the First Six Weeks

Every new backend project spends its first weeks on the same work. Sign-in and sessions. Background jobs that survive a restart. Events that don’t get lost when a transaction rolls back. Feature flags, tracing, email, file uploads, rate limits. None of it is the product. All of it has to exist before the product can be trusted in front of a customer. That’s the work a Scala backend template should take off your hands.

Most teams handle this in one of two ways. They adopt a framework that hides the infrastructure, and later discover they need to change exactly the part the framework hid. Or they start from a minimal starter, ship the first feature fast, and leave the hard pieces for later. Later is when the first customer reports a lost notification.

> “Fundamentals didn’t become optional with AI. They became the ground humans and agents both build on. Good defaults are expensive to build and cheap to start from — nobody should start from zero. We’ve been shaping ours at Iterators since 2013. madrileño is that work, as code anyone can take.”
>
> ![Łukasz Sowa](https://imagedelivery.net/xbsYPGC2JzolxD8T9X951A/www.iteratorshq.com/2026/06/lukasz-sowa-smiling-portrait-photo.jpeg/w=9999)
>
> Łukasz Sowa
>
> Managing Partner @ Iterators

[madrileño](https://github.com/madrileno-dev/madrileno) is our answer: an open-source Scala backend template, built on Scala 3, that ships the first six weeks as plain code you clone, rename, and own. It comes with a React web app and a React Native mobile app that are built against a contract generated from the backend’s tests, so a renamed field is a compile error on the client, not an incident in production. And it’s set up from the first commit for a coding assistant working in the loop. This is a precise account of what’s in it, how the pieces fit together, and where it doesn’t fit.

Same first six weeks, three different month sixes

Drag time forward and watch where each way of starting a backend ends up.

Framework

Minimal starter

madrileño

Source: madrileño manifesto and docs, madrileno.dev

## The work that isn’t the product

Start with the list. Before a typical B2B product can ship its first real feature, someone has to build:

- Authentication against an identity provider, plus refresh tokens that rotate and can be revoked
- A scheduler for recurring and one-time jobs that doesn’t run the same job twice on two instances
- A transactional outbox, so a domain event is published if and only if the transaction that produced it committed
- Email with templates that someone can preview without sending
- File uploads to object storage, with image processing
- Feature flags with targeting, so a change can be rolled out to 5% of users and rolled back without a deploy
- Rate limiting on the endpoints that will be abused first
- Tracing, metrics, and logs that connect a user’s complaint to the exact request that failed

Each of these is a few days to a few weeks of work. Each has a naive version that works in the demo and a correct version that works in production. The difference between the two is where bugs live for years: a scheduler without a lock that sends the same email twice, an event published before the transaction commits, a refresh token that can be replayed.

![scala backend template madrileno demo vs production](https://www.iteratorshq.com/wp-content/uploads/2026/10/scala-backend-template-madrileno-demo-vs-production.webp)

The madrileño [manifesto](https://madrileno.dev/manifesto/) names the pattern directly. Customers spend their day in Linear, Stripe, and their banking app, and they won’t lower their expectations because the team is five people. If real-time updates, email, image handling, and mobile aren’t ready before the first feature, they get postponed until month six, “and month six never comes.”

At Iterators, this list stopped being a design question years ago. We solved each item once, kept the working version, and carried it from project to project, improving it every time a system went to production. madrileño gathers those versions into one codebase, tested and published.

## A Scala backend template, not a framework

The distinction matters more than it sounds.

A framework is a dependency. Your code calls into it, and its code calls into yours. When you need behavior it doesn’t support, you work around it, fork it, or wait for the next release. Its upgrade schedule becomes your upgrade schedule.

A Scala backend template is a starting point. madrileño ships as a repository with a working showcase domain (wine auctions: listings, bids, images, outbid emails, live updates over WebSockets). You run one script:

terminalShell

// hover or tap the underlined names

```
./scripts/init-project.scala my-project
```

It renames the package, removes the auction demo, deletes the template's license file, and drops the local `docs/` (pass `--keep-docs` to keep them). What's left is your repository. There's no runtime dependency on madrileño, no base class to extend, no plugin API. Every building block is a Scala class or trait you can `cmd-click` into and read end to end. The project's own [principles](https://madrileno.dev/docs/principles/) put it as staying "one level of abstraction away from framework."

The license reflects this. The template is Apache-2.0, but projects generated from it are explicitly unencumbered: you can relicense the generated code under any terms, with no attribution required.

The tradeoff is real and worth stating. With a template, you own the code, which means you own its maintenance. A framework ships fixes through a version bump. A template can't. We come back to how madrileño handles that below, because it's the hardest problem a template has to solve.

## Backend drift is a compile error

The single most useful thing madrileño does for a team with a web or mobile client is this: the backend and the clients cannot silently disagree.

The contract between them is generated, not written. The backend's router tests (more on them in the walkthrough below) produce an OpenAPI spec and a typed TypeScript contract, and the web and mobile apps are built on that contract.

A team can get the same guarantee another way: write the backend in TypeScript too and share types across the whole stack. That works, but it makes TypeScript the language of the entire system, from the UI down to the database layer. madrileño keeps the guarantee and lets the backend stay Scala. The types cross the boundary through a generated contract, not a shared language.

![scala backend template madrileno api contract pipeline](https://www.iteratorshq.com/wp-content/uploads/2026/10/scala-backend-template-madrileno-api-contract-pipeline.webp)

Rename a field in a backend DTO. Run the tests. Sync the contract into the [web app](https://github.com/madrileno-dev/madrileno-frontend) or the [mobile app](https://github.com/madrileno-dev/madrileno-mobile). The TypeScript compiler now fails at the exact line that read the old field. The boundary between backend and client is checked by two compilers.

Errors cross the boundary too. Every backend error follows the RFC 9457 problem format and carries a stable, machine-readable code. The client decodes that envelope into discriminated errors and dispatches on the code (for example `bid-too-low`, which the walkthrough below follows end to end), never on the display text. A specific problem code becomes a specific message in the UI.

One gap remains in a scheme like this: a router that can return an error no test exercises. That error would be missing from the contract, and the client would have no case for it. madrileño closes the gap with an sbt task, `verifyErrorCodes`, that runs in CI. It collects every error code the routers can emit, compares it with the codes documented in the generated OpenAPI spec, and fails the build if any are missing. Forget to test a failure path and the build tells you before the client does.

The companions are deliberately separate repositories, not a monorepo and not a fullstack framework with shared types. The backend doesn't know they exist. Its only coupling to them is the contract it already generates for its own documentation, so either side can be adopted, replaced, or deleted without touching the other.

- **Web:** React 19, Vite, strict TypeScript, TanStack Query, react-hook-form with zod, shadcn/ui on Tailwind v4, PWA, server-side rendering as an opt-in for public pages.
- **Mobile:** Expo, Expo Router, NativeWind, over-the-air updates through EAS, deep links, Maestro end-to-end tests.

Both support optional real-user monitoring that joins the backend's traces. One trace runs from a tap in the app, through the HTTP call, to the Postgres query, and searching one user ID finds both their sessions and their backend traces.

## What's in the box

The backend stack is deliberately boring, in the sense the manifesto means: predictable beats popular.

| Layer | Choice |
| --- | --- |
| Language and runtime | Scala 3, JDK 21 |
| HTTP | http4s (Ember server) with [http4s-stir](https://github.com/theiterators/http4s-stir) as the routing DSL |
| Database | Postgres via Skunk, migrations with Flyway |
| Effects | cats-effect |
| Observability | otel4s and OpenTelemetry: traces, metrics, logs |
| Testing | ScalaTest, Testcontainers (a real Postgres per test run) |
| Local dev stack | docker compose: Postgres, Mailpit, OpenObserve, S3-compatible storage |

On top of that, twelve modules come ready to use:

- **Auth and sessions.** Firebase, any OIDC provider, or a dev login for local work. JWT access tokens, rotating refresh tokens, revocation of a whole token family on logout.
- **Scheduler.** Recurring (cron) and one-time jobs, Postgres-backed.
- **Transactional outbox.** Retries, dead letters, replay.
- **Event bus.** In-process, for reactions that don't need durability.
- **WebSockets.** Sharing the same auth gate, prefix, logging, and tracing as HTTP routes.
- **Feature flags.** Rules, segments, targeting, percentage rollouts, an audit log.
- **File storage.** Disk or S3, presigned uploads, image processing.
- **Rate limiting.** Per route, per user or per IP.
- **Observability.** Tracing, metrics, structured logs, admin endpoints for loggers, thread dumps, and heap dumps.
- **Mailer.** Typed templates with a dev-only preview route.
- **External API gateways.** A pattern with retries and a circuit breaker.
- **Cache.** Local by default, swappable.

The swap points are explicit. Cache, storage, event bus, rate limiter, and circuit breaker are each a small `Runtime` interface with factory methods on the companion object. Production picks one in `Main`, tests pick an in-memory variant, and the call sites never know which. Replacing the local cache with Redis is one factory change.

## One request, end to end

The clearest way to show how madrileño is built is to follow one endpoint through every layer. Placing a bid on an auction touches a domain type, a service with several ways to fail, a router, and a test. It's also where all four Iterators open-source libraries meet in one place.

**The domain types: kebs.** Identifiers and money are opaque types, not raw `UUID` and `BigDecimal`:

auction/domain/Bid.scalaScala

// hover or tap the underlined names

```
import pl.iterators.kebs.opaque.Opaque

opaque type BidId = UUID
object BidId extends Opaque[BidId, UUID]
```

That one line is all a new identifier costs. [kebs](https://github.com/theiterators/kebs) derives the JSON codecs, the path matchers, and the rest of the typeclass instances, so the compiler can refuse to accept a `UserId` where a `BidId` belongs without anyone writing the plumbing. (We wrote about why that plumbing is a tax in [Typed Domain Modeling in Scala Has a Tax](https://www.iteratorshq.com/blog/typed-domain-modeling-in-scala-has-a-tax-kebs-library-eliminates-it/).)

**The outcomes: an enum, not an exception.** Placing a bid can succeed in one way and fail in seven. Every one of them is a case:

auction/services/AuctionService.scalaScala

// hover or tap the underlined names

```
enum PlaceBidResult {
  case BidPlaced(bid: Bid, auction: Auction)
  case AuctionNotFound
  case AuctionNotOpen
  case AuctionNotStarted
  case AuctionEnded
  case CannotBidOnOwnAuction
  case AlreadyHighestBidder
  case BidTooLow(minAmount: Price)
}
```

Business outcomes are values returned from the function. Exceptions are reserved for things that are actually exceptional: a dropped connection, a bug. When a method returns `IO[PlaceBidResult]`, every way it can end is readable in the type.

**The flow: sealed-monad.** The service locks the auction row, checks the previous highest bid, asks the domain whether the bid is allowed, and saves it. Each step can short-circuit to one of the cases above. With [sealed-monad](https://github.com/theiterators/sealed-monad) the flow reads top to bottom:

auction/services/AuctionService.scalaScala

// hover or tap the underlined names

```
// trimmed: the feature-flag lookup and the post-commit event publish are omitted
transactor.inTransaction {
  (for {
    locked <- auctionRepository
                .findForUpdate(command.auctionId)
                .valueOr[PlaceBidResult](PlaceBidResult.AuctionNotFound)
    previousHighest <- bidRepository.highestBid(command.auctionId).seal
    now             <- Clock[IO].realTimeInstant.seal
    bidId           <- IdGenerator.generateId(BidId).seal
    bid             <- locked
                         .placeBid(command.bidderId, command.amount, bidId, now, previousHighest, minIncrementPct)
                         .left
                         .map {
                           case BidRejection.AuctionNotOpen        => PlaceBidResult.AuctionNotOpen
                           case BidRejection.AuctionNotStarted     => PlaceBidResult.AuctionNotStarted
                           case BidRejection.AuctionEnded          => PlaceBidResult.AuctionEnded
                           case BidRejection.CannotBidOnOwnAuction => PlaceBidResult.CannotBidOnOwnAuction
                           case BidRejection.AlreadyHighestBidder  => PlaceBidResult.AlreadyHighestBidder
                           case BidRejection.BidTooLow(minAmount)  => PlaceBidResult.BidTooLow(minAmount)
                         }
                         .rethrow[IO]
    saved <- bidRepository.save(bid).seal
    _     <- notifyOutbid(previousHighest, locked, saved.amount).seal
  } yield PlaceBidResult.BidPlaced(saved, locked)).run
}
```

Read it as a list of steps. A missing auction ends the flow with `AuctionNotFound`. A rule the domain rejects ends it with the matching case. The rules themselves (is the auction open, is the bid high enough) live in the domain method `placeBid`, not in the service. There's no nested `flatMap`, no pattern match three levels deep. (The full argument is in [sealed-monad: Linear Business Flows as Algebraic Data Types](https://www.iteratorshq.com/blog/sealed-monad-linear-business-flows-as-algebraic-data-types/).)

**The route: one HTTP answer per case.** The router, written with http4s-stir, matches every case of the enum. Each error has a stable, machine-readable code:

auction/routers/AuctionRouter.scalaScala

// hover or tap the underlined names

```
auctionService.placeBid(command).map[ToResponseMarshallable] {
  case PlaceBidResult.BidPlaced(bid, _)     => Created -> BidDto(bid)
  case PlaceBidResult.AuctionNotFound       => error(NotFound, "auction-not-found", "Auction not found")
  case PlaceBidResult.AuctionNotOpen        => error(Conflict, "auction-not-open", "Auction is not open")
  case PlaceBidResult.AuctionNotStarted     => error(Conflict, "auction-not-started", "Auction has not started yet")
  case PlaceBidResult.AuctionEnded          => error(Conflict, "auction-ended", "Auction has already ended")
  case PlaceBidResult.CannotBidOnOwnAuction => error(Forbidden, "cannot-bid-on-own-auction", "Cannot bid on your own auction")
  case PlaceBidResult.AlreadyHighestBidder  => error(Conflict, "already-highest-bidder", "You already have the highest bid")
  case PlaceBidResult.BidTooLow(minAmount)  =>
    error(Conflict, "bid-too-low", "Bid is below the current minimum", extension = BidTooLowExtension(minAmount))
}
```

Add a ninth case to the enum and this match stops compiling until someone decides what HTTP answer it gets. Errors follow the RFC 9457 problem format, so `bid-too-low` also carries the minimum acceptable amount, and a client can show "the minimum bid is 100" instead of a generic error.

**The spec: baklava.** The router test is a [baklava](https://github.com/theiterators/baklava) spec. Each case is one request and one expected response:

test/…/auction/routers/AuctionRouterSpec.scalaScala

// hover or tap the underlined names

```
supports(
  POST,
  description = "Place a bid on an auction",
  summary = "Authenticated: places a bid above the current highest",
  securitySchemes = Seq(bearerScheme),
  pathParameters = p[AuctionId]("auctionId"),
  tags = Seq("Auctions")
)(
  withSetup(setupAuction(alsoSeedBidder = true))
    .request(placeBidRequest(150, bidderAuth))
    .respondsWith[BidDto](Created, description = "Bid placed")
    .assert { case (ctx, auction) =>
      val response = ctx.performRequest(allRoutes)
      response.body.amount shouldBe Price(BigDecimal(150))
    },
  // ...one entry per remaining case
  withSetup(setupAuction(alsoSeedBidder = true))
    .request(placeBidRequest(50, bidderAuth))
    .respondsWith[Error[BidTooLowExtension]](Conflict, description = "Bid below minimum; minAmount carries the lowest acceptable bid")
    .assert { case (ctx, _) =>
      val response = ctx.performRequest(allRoutes)
      response.body.extension.minAmount shouldBe Price(BigDecimal(100))
    }
)
```

These are real tests against a real Postgres. They are also the API documentation. When the suite passes, baklava writes out what it observed: the OpenAPI spec behind the Swagger UI, and the typed TypeScript contract the web and mobile apps compile against. The test and the documentation can't disagree, because they are the same thing. (More in [Baklava: Generate API Documentation and Type-Safe Clients from Scala Routing Tests](https://www.iteratorshq.com/blog/baklava-generate-api-documentation-and-type-safe-clients-from-scala-routing-tests/).)

That's the whole vertical slice. Typed IDs from kebs, a linear flow from sealed-monad, routing from http4s-stir, contract from baklava. None of the four libraries was written for madrileño. They were written over a decade of client work, one problem at a time. madrileño is the first place they're published working together.

## Built for an assistant in the loop

madrileño describes itself as AI-first. It's worth being precise about what that means, because the phrase has been stretched to cover almost anything.

It doesn't mean generated code or an AI feature in the product. It means the repository is set up so a coding assistant gets the same fast, precise feedback a careful engineer would want. The manifesto's argument is the core of it:

> An agent works in a loop: write, build, test, fix. A type error that shows up ten seconds later is feedback it can act on. A runtime error in production is feedback it never sees.

Everything above (opaque types, result enums, exhaustive matches, a contract checked by two compilers, `verifyErrorCodes`) moves errors earlier. That helps a person. It helps an assistant more, because an assistant can't notice that something feels off. It can only react to what the build tells it.

Concretely, the repository commits three things:

- **A `.mcp.json`** wiring two MCP servers. Metals gives the assistant a real compile, test, and dependency-lookup loop on your code instead of guessing from text. The second server, a read-only madrileño server, is described below.
- **An `AGENTS.md`** with the project's conventions: compile and test through tools, format before committing, prefer ADTs and pure functions, avoid shared mutable state.
- **35 pages of documentation** written to be read by a person on day one and by an assistant on every run. They include the unglamorous parts: which ports the dev stack uses, what catches people out, and when not to use a pattern.

The read-only madrileño server is the least common part of the setup. It exposes the upstream template at a pinned commit, so when the assistant needs to add something to your project, it can look up how the template already does it (how a module wires a scheduled task, how a repository handles soft delete) and copy an established, compiling pattern rather than invent one.

This is an implementation of an idea Birgitta Böckeler of Thoughtworks describes in [Anchoring AI to a reference application](https://martinfowler.com/articles/exploring-gen-ai/anchoring-to-reference.html) on martinfowler.com. Her observation is that keeping code samples in a reference application you can compile and run "makes it a lot easier to provide AI with compilable, consistent samples." In madrileño, the template itself is that reference application, and the MCP server is how the assistant reaches it.

The setup targets Claude Code today. Both MCP servers are optional; the project builds and runs without them. The manifesto's point 11 says the rest: AI multiplies what you already have. Conventions, readable feedback, logs, CI. The same things that let a new hire ship on day one let an agent do useful work on every pass.

## The hard problem of every template: updating

Here's the tradeoff we promised to come back to. You generate a project from a template, rename the package, delete the auction module, change the auth provider. Six months later, upstream has fixed a connection leak in the scheduler and tightened token rotation. How do you get those changes?

The usual answers don't work. A mechanical merge produces conflict soup, because your files and upstream's files stopped being the same files months ago. Template-update tools assume a structure you've already changed.

madrileño's answer is judgment-based porting, done with the assistant. Your project carries a pin, `.madrileno-ref`, that records the upstream commit you've triaged up to. The read-only MCP server then answers the questions a porting decision needs: what changed upstream since the pin, which files, the actual patch rewritten into your package name, and the full upstream file at either commit. You, or the assistant with you, decide per change whether it applies, adapt it to your code, and bump the pin.

This is the second half of the reference-application idea. Böckeler's article goes on to use the reference application's git commits to detect where a generated codebase has drifted from it. madrileño's porting workflow is built on the same mechanism: upstream commits, scoped by the pin, as the unit the assistant reasons about.

![scala backend template madrileno port dont merge](https://www.iteratorshq.com/wp-content/uploads/2026/10/scala-backend-template-madrileno-port-dont-merge.webp)

The pin means "I have triaged upstream up to here," not "I have adopted everything." Skipping a change is a decision, and bumping the pin records that it was made. Without that distinction, every deliberate skip would come back as noise in the next review, until the noise drowned the signal.

It's slower than `npm update`. It's also the only version of "updating a template" we've seen survive real divergence.

## What madrileño doesn't do

![scala backend template madrileno scope boundaries](https://www.iteratorshq.com/wp-content/uploads/2026/10/scala-backend-template-madrileno-scope-boundaries.webp)

**It isn't neutral about the stack.** madrileño is Typelevel all the way down: http4s, cats-effect, Skunk, otel4s. If your team is on ZIO, Pekko HTTP, or Play, the patterns transfer, but the code doesn't. Our libraries do support those stacks individually (kebs and baklava both work with Pekko HTTP), but the Scala backend template picks one stack and commits to it.

**It assumes Postgres.** The scheduler, the outbox, the feature flags, and the advisory locks all use Postgres. That's a deliberate consequence of the manifesto's "every moving part pays rent": a few machines and one well-configured Postgres are enough for most products. It also means there's no MySQL or MongoDB path.

**It isn't a microservices platform.** One deployable backend, a web app, a mobile app. No service mesh, no Kafka. The manifesto is explicit that this isn't a ban: when those tools earn their keep, use them. They're not in the starting point.

These are scope boundaries, not gaps. madrileño is a Scala backend template: a starting point for a small team building a product. It's opinionated where opinions save time, and everything it decides is code you can change.

## Getting started

To run the Scala backend template, you'll need JDK 21, sbt, a recent scala-cli, and Docker.

terminalShell

// hover or tap the underlined names

```
git clone https://github.com/madrileno-dev/madrileno.git my-project
cd my-project
./scripts/init-project.scala my-project   # rename, drop the demo (skip this to explore the auction showcase first)

docker compose up -d                        # Postgres, Mailpit, OpenObserve, S3-compatible storage
cp .env.sample .env
sbt "runMain myproject.main.MigrateMain"
sbt
> ~reStart
```

The app comes up on `http://localhost:9000`. `./scripts/doctor.scala` checks the whole setup at once. After the first `sbt test`, the generated Swagger UI is at `/swagger`.

To add a feature, `./scripts/scaffold-module.scala` generates a module skeleton: domain type, repository, service, router, DTO, migration, and the matching tests. [docs/adding-a-module](https://madrileno.dev/docs/adding-a-module/) walks through a full vertical slice from migration to OpenAPI.

Documentation: [madrileno.dev/docs](https://madrileno.dev/docs/getting-started/). Manifesto: [madrileno.dev/manifesto](https://madrileno.dev/manifesto/).

GitHub: [madrileno](https://github.com/madrileno-dev/madrileno), [madrileno-frontend](https://github.com/madrileno-dev/madrileno-frontend), [madrileno-mobile](https://github.com/madrileno-dev/madrileno-mobile).

## Where the idea came from

The name is a clue. madrileño is Spanish for someone from Madrid, and the idea for the project took shape at Scala Days Madrid 2023.

The code behind it is older than the name. Iterators has used an internal backend template on its projects for years, and it improved with every production system that started from it. madrileño began as an after-hours effort to take that template, together with what those years taught us, and write it down properly in the open: one codebase anyone can clone, read end to end, and own.

Writing it down turned out to be the easy part. The hard part was deciding what it shouldn't be. Not a framework, because every framework we'd used eventually hid the thing we needed to change. Not a minimal starter, because the hard parts are exactly what makes a starting point worth having. A Scala backend template, with a demo domain realistic enough to exercise every module, and a way to keep pulling fixes after you've made it your own.

The AI-first shape came later, and it wasn't a pivot in what the code does. It was a realization about who reads it. The properties we'd always wanted for a new engineer (conventions in writing, errors at compile time, a contract nobody maintains by hand) turned out to be exactly what an assistant needs to do useful work. Writing the documentation for both readers at once made it better for each.

It also became the place where our open-source libraries finally sit next to each other. kebs, sealed-monad, baklava, and http4s-stir each came out of a specific problem on a specific project. madrileño is what a backend looks like when all four problems are solved at once.

## The position

A small team's scarcest resource is the window to prove the product is worth keeping. Every week spent on auth, jobs, events, and tracing is a week not spent on the thing customers pay for. And every shortcut taken there to save that week charges interest on every change afterwards.

madrileño's bet is that this infrastructure should be solved once, correctly, and handed over as code you own, with the boundaries checked by the compiler rather than by review. Errors move to build time. The contract between backend and client is generated, not written. The assistant gets a feedback loop instead of a guess.

If you're starting a Scala backend in the next few months, clone it and read one module end to end before you write your own. If you're not on Scala, read [the manifesto](https://madrileno.dev/manifesto/). Most of it isn't about Scala at all.

At Iterators, we build and maintain production Scala backends, web, and mobile apps, and we develop the open-source libraries that come out of that work. madrileño is that work in the open. If you'd like to build on it with the team that wrote it, whether that's a kickoff, a full build, an architecture review, or ongoing support, [let's talk](https://madrileno.dev/support/).
