Case study
getMickled
A multi-tenant commerce platform, grown from one shop's store into storefronts, tenant admin and an operator console on one serverless AWS stack.
- Client
- Bates Solutions
- Dates
- 2019 to now
- Live
- getmickled.com
- merged PRs
- 1,091
- Terraform modules
- 19
- tenant storefronts live
- 2
- lines of Terraform for a tenant's backend in dev, down from 588
- 87
- Angular
- TypeScript
- Nx
- AWS Lambda
- DynamoDB
- Cognito
- Terraform
- Square
The problem
Mandi’s Mickles has sold online since 2019 on a store I built for it. That store served one business. Serving a second by copying the codebase would have meant fixing every bug twice, then three times, then once per client forever.
So in December 2025 I converted the store into a platform and called it getMickled. That makes mandismickles.com and getmickled.com one project: the store is the platform’s first business. getMickled runs every business from one codebase: each gets its own storefront, its own admin site, its own data and its own payment and shipping accounts, and a fix ships to all of them at once. Bisque & Co became the second business on it in June 2026. The hard rule underneath: one business must never be able to see or change another’s.
My role
Founder and principal engineer. I designed and built the platform alone: the Angular apps, the Lambda backend, the Terraform, and the move from one store to many. For Mandi’s Mickles I’ve also handled the business side beyond the software.
What I built
- Seven apps on one Nx monorepo. Two live storefronts (Mandi’s Mickles and Bisque & Co), a tenant admin site, an operator console for running the platform, the getMickled marketing site, and two end-to-end test suites.
- A serverless backend. Node 22 Lambdas behind API Gateway, with DynamoDB and Cognito, all in Terraform across 19 modules and deployed by CI through GitHub OIDC.
- Data and secrets per tenant. Each business gets its own admin table, and its secrets (Square, Shippo and the rest) live in Parameter Store under a path for that business and environment, so a development role can never read a production secret. The code refuses any secret that isn’t stored encrypted.
- Checkout that knows the products. A box is chosen from the order’s item dimensions, shipping rates come from the business’s own Shippo account through a Lambda (the key used to sit in the browser bundle), and local delivery zones are map polygons with a fee each, holes included, matched against the server-geocoded address.
- Payments through Square, connected per business, with each Square merchant claimable by exactly one tenant.
Architecture
Every storefront and admin site is a static Angular app on S3 and CloudFront. The hostname picks which business’s configuration loads, but it only decides what you see. Admin requests are scoped on the server by the business in the user’s verified Cognito token. Public requests are moving to a gateway per business, built and running for Bisque & Co in development.
The hard part
Taking the tenant out of the request
The shared backend works out the tenant from the request: the hostname, or a tenant header. That’s fine for picking a logo and wrong for anything that matters, because a caller controls both. The public contact form shows why: had it used that lookup, anyone could have stamped a message with another business’s tenant and dropped it in that business’s inbox. It was written to read its tenant from its own deployment instead, and so were the feedback and orders routes.
The goal is a tenant that never comes from the request. It comes from one of two places.
Admin requests use the verified token. Each admin user’s Cognito token carries their business, and the admin Lambdas pick the table from that claim, never from anything in the request. An unknown tenant fails closed with a 403. This is how production works today.
Public requests use the gateway. Each business gets its own API gateway and its own copies of the Lambdas, and the tenant is simply the gateway the request arrived at, with nothing in the request to forge. Until that reaches production, checkout and shipping rates on the shared gateway still take the tenant from the header, and closing that gap is what this work is for.
Giving every business its own backend could have meant hundreds of lines of Terraform per tenant per environment: Bisque & Co’s first version took 588. So I folded it into one module. A business now declares which routes it serves, and the module builds the functions, their roles, the gateway and the secret grants from that list. Bisque & Co’s block went from 588 lines to 87, and the next tenant needs two routes.
Deriving the grants from the routes wasn’t only about saving lines. Reviewing the first version, I found a flag that gave Bisque & Co’s catalogue publisher write access to every business’s catalogue snapshot, Mandi’s Mickles’ included, for a function it didn’t even run. I confirmed it in live IAM, then made the grant exist exactly when the function does, so it can’t be switched on by hand, copied by mistake or outlive the route that needed it.
The per-business gateway runs for Bisque & Co in the development environment; production is the next step. The module’s own notes are just as plain about what it doesn’t cover yet.
Outcome
- Two businesses live on one codebase: Mandi’s Mickles, selling, and Bisque & Co, whose storefront is up ahead of taking orders.
- In development, a new business’s backend is a list of routes: 87 lines where the first one took 588.
- Two quiet failures found and fixed in production: a deploy-script bug, about six months old, that took Mandi’s Mickles’ storefront and the admin site down the day their configuration file became load-bearing, and browser geocoding that Google had silently rejected, which had stopped local delivery working.
- 1,091 merged pull requests on the platform, across 19 Terraform modules.