The technical questions we get from users tend to be broad ones:
What does your architecture look like?
I've got more than ten thousand users. Are you going to fall over?
How do you keep my data safe?
There's no one-line answer to any of them. Whether a platform holds up, and whether it keeps your data safe, comes down to a handful of choices it made along the way, and what each of those choices cost. In this post I'll walk through the key decisions we have made since 2020: the constraints we faced, the options we considered, why we chose what we have today, and what each choice costs. Several of these decisions started out quite differently; we revised them as we learned more.
Momen doesn't generate code for your project. Every project runs on one shared runtime that executes the app definition you build in the editor. Your data lives in PostgreSQL, in a schema of its own. We wrote our own data API and permission layer. Business logic runs right next to the data, inside a single transaction by default. And when AI builds something for you, it edits that app definition, not code.
The thread running through all of it: complexity belongs in the platform, not with the founder. That makes the platform harder to build and gives users somewhat less freedom. We think that is the right trade.
┌──────────────────────────────────────────────────────────┐
│ Editor (browser) │
│ Pages, data model, Actionflows; real-time collaboration; │
│ AI agent │
└────────────────────────────┬─────────────────────────────┘
│ app definition
┌────────────────────────────▼─────────────────────────────┐
│ Control plane │
│ Stores app definitions; on publish, generates database │
│ changes and syncs the runtime; manages runtime servers; │
│ rolls out runtime upgrades │
└────────────────────────────┬─────────────────────────────┘
│ publish
┌────────────────────────────▼─────────────────────────────┐
│ Runtime (shared by every project) │
│ Frontend runtime: web, native apps │
│ Backend runtime: data API, permissions, Actionflows, │
│ scheduled jobs │
└────────────────────────────┬─────────────────────────────┘
│
┌────────────────────────────▼─────────────────────────────┐
│ Data layer │
│ PostgreSQL: one schema per project │
│ Redis: caching, locks Object storage + CDN: files, assets│
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ Infrastructure: everything above runs on Kubernetes │
└──────────────────────────────────────────────────────────┘
The editor runs in your browser. It's where you build pages, design your data model and wire up Actionflows, or ask the AI to do it for you. Everything you do ends up in a single structured "app definition": which pages exist, what's on them, which tables and fields you have, and what happens when each button is tapped. When several people edit at once, they're all editing that same definition.
The control plane looks after app definitions. When you publish, it turns yours into a running app: it diffs the old and new data models, generates and applies the database changes, syncs the new definition to the backend runtime, and pushes your frontend files to the CDN. It also manages every runtime server, and when we ship a new version of the runtime, it's the control plane that rolls it out.
The runtime is shared by every project. The frontend runtime reads your app definition and renders it as a web page or a native app. The backend runtime reads your data model, serves your project's data API, checks permissions, and runs your Actionflows and scheduled jobs.
The data layer is built around PostgreSQL. Each project gets its own schema, and every table in it is a real database table. Redis handles caching and distributed locks. Files your users upload, plus the pages and assets your app publishes, live in object storage (Cloudflare R2) and are served through a CDN.
Underneath it all is Kubernetes, running on AWS. The control plane, the backend runtime and PostgreSQL all run as containers. A "runtime server" is a group of Kubernetes resources: one backend runtime, one PostgreSQL database, and one database for runtime logs. Starting a new server, upgrading it, and moving a project from one server to another are all automated through the Kubernetes API.
We picked Kubernetes for the same reason we picked our database: we do not want to be locked into a single cloud vendor. The same deployment can run on another cloud, or inside a customer's own cloud account (more on that below).
We also run PostgreSQL ourselves rather than using a managed service like Amazon RDS. That comes down to cost and control. On a managed service, every runtime server would need its own instance, and instances only come in fixed sizes, while most of our databases need very little. Running them ourselves is cheaper. Control means we decide the PostgreSQL version, the extensions, and when to upgrade, and many extensions are simply not available on managed databases.
Here's a concrete example. We're rolling out BM25 keyword search in 43 languages. For 29 of them, English, French and Russian among them, we can use PostgreSQL's built-in stemmers, which run anywhere. The other 14 cannot be supported, or cannot be fully supported, on a managed database. To support CJK (Chinese, Japanese and Korean) and Thai, we needed a dedicated word segmenter for each. For Chinese, we imported an existing open-source segmenter, pg_jieba. For Japanese, Korean and Thai, we wrote our own PostgreSQL extension in Rust: it uses Lindera, with the IPADIC dictionary for Japanese and ko-dic for Korean, and kham-core for Thai, then maps each word's part of speech onto PostgreSQL's token types so the BM25 index can use it; Japanese words are also reduced to their dictionary form. Neither extension is allowed on Amazon RDS, which does not offer the BM25 extension itself either. Ten more, including Polish, Czech and Ukrainian, need dictionary files on the database server to reduce words to their base forms, and managed databases do not provide file-system access.
One more principle shows up everywhere: we avoid microservices wherever we can. Our backend is two applications, the control plane and the backend runtime. Each is split by domain into dozens of modules, and those modules talk to each other through ordinary function calls, not over the network.
Microservices solve a real problem: a team so large that people are blocked on each other in one codebase and one release pipeline. But they come at a steep price. Every extra service adds a network hop, which makes the system slower and more expensive. It adds a fresh way for an operation to half-succeed, because calls between services can't share a transaction. And it is one more thing to upgrade, monitor and keep dependencies current for. Our team is not large, and for us the cost far outweighs the benefit.
Over the past few years, we have mostly merged services rather than split them. In 2021, our data API engine moved into the backend runtime. In 2022, the backend runtime dropped RabbitMQ for a queue built directly on PostgreSQL, removing one more component to operate. In June 2026, our standalone AI service, along with its own database, was merged back into the control plane. The only components deployed separately today are those that must be: code that can only be written in JavaScript (frontend rendering, merging collaborative edits), work that needs a different storage engine (runtime logs), and anything that must be isolated for security, such as the cloud functions we are building now, where user-written code must never have a route into our internal network.
The rest of this post explains how each of these decisions came about.
This was the first decision we made. I wrote up the full selection process a few years ago, so here I will only summarize the conclusions.
Our hard requirements were: a single app should be able to serve on the order of a million users; ACID transactions; low cost; no cloud lock-in; and secondary indexes. That left three finalists: PostgreSQL, MySQL and MongoDB.
MongoDB's main appeal is that it does not require a schema. But every no-code platform maintains a schema internally anyway; users see "tables" and "fields", after all. If a schema exists regardless, it is better to have the database enforce it. Non-technical users are better served by a platform that imposes sensible constraints than by one that offers more freedom. Our job is to reduce mental load, not add to it.
Between PostgreSQL and MySQL, a few PostgreSQL features made the difference. Schema changes (DDL) can run inside a transaction, so they either all apply or all roll back, which matters a lot when you're running schema changes automatically and often. There's a single text type with no length limit, so users never have to guess in advance how long a piece of text might be. And LISTEN/NOTIFY fires when a transaction commits, which is exactly what live subscriptions need.
The deciding factor was the query planner. A no-code platform has no idea in advance what its users' data will look like or how they'll query it. Writing our own planner would have meant reinventing a wheel PostgreSQL has spent decades refining.
The costs were clear from the start: some schema changes lock the table, updates leave dead row versions behind that need cleaning up, and big data migrations run a bit slower than in a document database. We handle all of these inside the platform so that users never have to notice them. We would make the same choice today: a query planner and transaction system of that quality is not something we could have built ourselves.
The full analysis: CTO's Tech Notes: On the Topic of Momen's Database Selection
This is the most fundamental decision in the architecture; most of what follows builds on it.
When we started in 2020, we took the approach most people would consider natural: translate each app into code. On the frontend, we generated source code for every project, one copy per target platform, and built each one separately. On the backend, we generated Java code from each project's API definition and compiled it on the fly at runtime.
From 2022 onwards, we gradually abandoned that approach. The reason was complexity.
Generating code per project means the platform has to keep three things working at once: the templates that produce the code, a separate build pipeline for every target platform, and thousands of generated codebases, each slightly different from the rest.
On the backend, runtime compilation caused a more concrete problem. The compiler library we depended on didn't work with JDK 17, so when we tried to upgrade in February 2022, we had to roll back to JDK 11. In May, we removed runtime compilation and completed the Java 17 upgrade the same day. Code generation had directly blocked an infrastructure upgrade.
Today, every project runs on one runtime. We started rewriting the frontend in October 2022 as a shared runtime that reads the app definition and renders it directly, with no per-project code. New web projects moved onto it in July 2023, the remaining projects were migrated in bulk in October 2024, and in December we deleted the old code generator: more than 120,000 lines of it. The backend works the same way. Actionflows and the data API run straight from each project's definition.
When we fix a bug or upgrade a dependency on the backend, we change one codebase, ship one release, and every project gets it at once. Frontend fixes also only need to go into the one runtime, and each project picks them up the next time it publishes.
To ensure the frontend and backend behave identically, we went one step further. We wrote a type system in Kotlin that compiles to both the JVM and JavaScript, and it's shared by the editor, the backend and the runtime. When the backend runs an Actionflow, it evaluates expressions with the very same code the frontend uses. A formula returns the same result on the page as it does on the server.
This approach has two costs:
Interpretation has a performance cost. Reading a definition and then executing it is always one layer more than running code written for the job. We're exploring compiler-style optimizations for the frontend, without giving up the shared runtime.
There is no source code to take away. Your app's "source" is its app definition, and it won't run on its own outside Momen's runtime.
We accept this trade-off. Performance is something we can improve for every project at once by making the runtime faster, and your data can be exported whenever you like. The complexity of generating code per project, on the other hand, only ever grows with the number of projects.
In the beginning, every project got its own environment: its own Kubernetes namespace, its own PostgreSQL database, its own API service. Server costs were high.
In mid-2022 we changed that, because most projects get no traffic most of the time. Prototypes, apps in testing, apps not yet launched, and launched apps with few users all sit idle much of the time. One environment per project meant paying for a great deal of idle capacity. In March 2022, we first began putting inactive projects to sleep automatically. Then, in June and July, we moved to the model we have today: many projects share one runtime server. Existing projects were migrated across.
Projects are kept apart by schema, not by a tenant_id column. The other common way to do multi-tenancy is to have every tenant share the same tables and tag each row with a tenant_id. That doesn't work for us, because every project has a different data model. One has an "orders" table, another has "courses". The only way to fit them into shared tables would be a generic key-value or JSON structure, which is exactly what we ruled out when we chose our database.
A schema per project gives us a few things:
Every table is a real database table, with real column types, foreign keys and indexes, so the query planner can do its job.
Schema changes stay contained. Adding a field in one project never touches another.
A whole project can be moved. Export its schema, import it on another server, and the migration is complete.
The last point makes upgrades straightforward. Servers on paid plans host fewer projects, and a project that needs more room can have a server all to itself. If your data has to stay in your own cloud account, we offer BYOC (bring your own cloud): we set up Kubernetes in your account and deploy the whole runtime environment there.
The cost is that projects on the same server can affect each other. Projects on the same server share CPU, memory and a database. If one project's traffic spikes, the others' response times may fluctuate, and if the server itself has a problem, every project on it is affected. Dedicated instances and BYOC exist for projects that can't live with that. For most projects, sharing is far cheaper, and that's what makes the free plan and our lower-priced plans sustainable.
In 2020, each project's data API was served by Hasura, an open-source tool that turns a PostgreSQL schema into a GraphQL API. At the time, it was the fastest way to get started.
By December 2021, we had replaced it, for two main reasons.
First, anything beyond the API itself was difficult to build. Hasura is great at turning tables into an API, but an app is more than create, read, update and delete. With Hasura, business logic had to live in a separate code service, or even in SQL functions inside the database, and calling a third-party API meant writing code too. None of that was type-checked, so the platform had no way of knowing what a piece of logic took in or handed back, and no way to turn it into something users could configure by dragging and dropping. When we later added AI agents to apps, the problem became more pronounced.
Second, Hasura is written in Haskell. Few engineers write Haskell, and when something broke, fixing it ourselves was rarely practical.
We started on our own API engine in December 2020. It generates each project's GraphQL API at runtime from its data model. It was serving every project by December 2021, and in September 2022 we deleted the last of the Hasura code. To avoid breaking existing apps, the new engine kept Hasura's API format.
Owning the engine lets us optimize for how no-code apps actually behave. Nested queries are a good example. A request for "all orders, with each order's items and buyer", resolved level by level, produces hundreds or thousands of database queries: the classic N+1 problem. We compile the whole nested query into a single SQL statement and let the database run it in one go.
Permission checks live in this API layer, too. When a request comes in, we work out from the user's login token who they are and which roles they have. Then:
Row-level permissions, like "users can only see their own orders", are compiled into filter conditions in the SQL, merged with whatever the user is querying for, and run by the database. Because filtering happens inside the database, it can use indexes, and rows the user isn't allowed to see never get read.
Column-level permissions are checked against the fields the query asks for. A field the user can't see returns its own error, without failing the rest of the query.
PostgreSQL has its own Row-Level Security (RLS). We deliberately do not use it, for two reasons.
The first is overhead. For RLS to work, the database needs to know who's asking, which means running a few SET statements at the start of every transaction to set the role and user details. We once tried setting just one extra parameter (application_name) every time we took a connection from the pool, and the overhead was immediately visible in our monitoring. We ended up only turning it on for debugging. RLS requires more than that on every transaction, and in addition the database re-evaluates the policy for every row it reads.
The second is that permissions are about more than data. Whether someone can run a particular Actionflow, or call a particular API, is a permissions question too, and none of that lives in the database. Where we're heading is attribute-based access control (ABAC): deciding what's allowed based on attributes of the user, the data and the context. Rules like that have to live above the database.
The cost is that the security semantics are entirely our responsibility. The database provides no safety net, and every new query path has to be covered by permission checks. We still consider this the right choice: the API layer is the only place that can see the data, the operation and the user's attributes all at once, so it's the only place permissions can be complete.
In Momen, backend logic is built with Actionflows: you chain together steps like querying data, writing data, calling APIs, running code and calling AI. Actionflows run in the backend runtime, in the same data center as your database. Synchronous Actionflows run inside a single transaction by default, so if any step fails, every database change from that run is rolled back, as if it never happened.
Consider ticket sales as an example. A show has 500 seats, and a popular one sells out in under a minute. When a fan taps "Buy", the backend has to do five things: check the seat is still free, mark it as sold, create the order, redeem the fan's coupon, and issue the e-ticket.
The first benefit is all-or-nothing execution. Suppose step four, redeeming the coupon, fails. Inside a transaction, the first three steps roll back with it: the seat is free again, there's no order, and the data looks exactly as it did before the fan tapped "Buy".
Without a transaction, the first three steps have already taken effect. The seat is marked sold, but there is no ticket. The seat can never be sold again, and the fan never gets in. Worse, nothing reports the problem: there is no error, and the data is simply, silently wrong. To repair it, you would have to write "compensation" logic yourself: find half-finished orders and undo the earlier steps one by one. The compensation can fail as well, and then failed undos need handling too. For a founder without backend experience, that's close to impossible to get right. It's the same principle that drove our choice of database: "these steps either all happened or none of them did" is easy to reason about. "The first two worked, the third didn't, so now you have to notice that and undo the first two, and the undo might fail too" is not.
The second benefit is correctness under concurrency. Two fans go for the last seat, A12, at the same instant. If seats have a unique constraint, the database guarantees only one of them gets it. The other fan's step fails, their whole Actionflow rolls back, and no half-finished order is left behind.
If the check and the write are separated, both fans see "A12 is available" at the same moment, and both buy it. At a dozen orders a second, this is not an edge case; it happens at every show.
The third benefit is speed. Because Actionflows run in the same data center as the database, each database step is a round trip of about a millisecond, so all five steps take a few milliseconds.
If those five steps are instead issued one by one from somewhere far from the database, such as a user's browser calling the API step by step, or from edge functions spread around the world. Every step has to cross the network. At 150 milliseconds a round trip, five steps take 750 milliseconds, which users notice. The bigger problem is on the database side: every request holds a connection open for longer, so the same database can serve fewer people at once. And the database is almost always the hardest part of a system to scale.
The fourth benefit is that the logic cannot be bypassed. Logic that runs on the server can't be seen or tampered with by users. If those five steps lived in the frontend, anyone with some technical knowledge could skip "check the seat" and call the later APIs directly. And if a user closed the page halfway through, a half-finished order would be left behind.
When you do need code, Actionflows have a code node that runs in a time-limited sandbox, and the frontend supports Code Components.
This approach has two costs:
Some operations cannot be undone. An external API call, an AI call, a text message: once sent, they cannot be recalled, so they sit outside the transaction. Synchronous Actionflows also have a time limit.
Asynchronous Actionflows don't get a single transaction. Long-running flows need to run asynchronously, and each step commits on its own. Any Actionflow with an AI agent in it has to run asynchronously.
We still make transactions the default, because most business logic is a group of database operations to begin with. Flagging the few steps that can't be rolled back is far safer than having no transactions at all.
AI can now write full-stack apps. In Momen, however, the AI agent does not write code.
We took several wrong turns along the way. Our first AI assistant shipped in the editor in August 2023 and was pulled in January 2024. In March 2025, we spun AI out into a standalone service with its own deployment and database. In June 2026, we deleted that service, along with several earlier attempts, nearly 30,000 lines of code all told, and merged AI back into the backend. The agent has been on its current architecture since July.
What the agent can do is a fixed set of typed operations. Add a table, add a field, add a component to a page, attach an Actionflow to a button, set a permission rule: each of those is an operation, and there are more than three hundred of them. Every operation's parameters are generated from the same type system described earlier, and every call the agent makes is checked against it first. Invalid parameters get rejected. Those operations are the only thing the agent can produce.
We built it this way for a few reasons:
It constrains what the AI can express. The agent cannot write arbitrary code, so it cannot introduce race conditions, skip a transaction, build an injectable SQL query, or pull in an arbitrary dependency. Locking, concurrency, scaling and transactions live in the runtime and the database, completely out of the agent's reach. The agent only deals with your business: what data you have, how it fits together, who can see what, and what happens when someone taps a button.
AI-built apps run on the same runtime as everything else. The shared upgrades we talked about earlier apply to them too. When we patch a vulnerability or make something faster, AI-built apps get it just like hand-built ones, and there is no generated code to maintain separately.
You and the agent edit the same definition. The agent runs on our servers, but its changes are batched up and sent to your open editor, which applies them to the shared document. You see the changes in real time, can keep editing alongside the agent, and can take over at any point. You never have to read and understand code written by AI.
The set of operations can keep growing. The agent doesn't get all 300-plus operations at once. It always has a handful of basic tools, and operations for each area, like the database, Actionflows, permissions or payments, get loaded as plugins when the task calls for them. That keeps the start of every prompt identical so the model can cache it, and adding new operations does not overflow the model's context.
Two further design choices make the agent both controllable and useful:
You always have the final say. In build mode, anything that changes your app needs your approval first. When the agent has a question or wants to propose a plan, it stops and waits for you.
The same operations are open to other tools. AI coding tools like Cursor and Claude Code can work on a Momen project through our command-line tool and MCP server. They use the same operations and go through the same type checks. Irreversible actions, such as deleting a project, open a dialog that a person must confirm by hand, so an external AI cannot approve its own actions. Whichever agent turns out to be the best, ours or someone else's, users can use it.
Since September 2026, every agent session also gets its own disposable sandbox machine. Before that, the agent could read and change your app definition and call your app's API, but it couldn't download a file, read a PDF, convert an image or run a build. It also depended on your editor being open, so closing your laptop stopped its work. The sandbox comes with a shell and a toolchain, and it can apply the same operations in place of your editor. The agent can now do all of this, and it keeps working while your editor is in the background.
The cost is that the agent can only do what the platform supports. Every new capability needs a new typed operation first. Anything the platform doesn't cover yet has to go through a code node or a Code Component. We accept that. For non-technical users, AI that produces reliable results matters more than AI that can do anything.
"What does your architecture look like?" Every project runs on one shared runtime that executes the app definition you build in the editor, and your data lives in PostgreSQL, in a schema of its own.
"I've got more than ten thousand users. Are you going to fall over?" Ten thousand users is a modest load for a real PostgreSQL database. Two examples of apps built on our platform:
MiniCollector, an app where sports-card collectors catalogue, trade and consign their cards: 57,000 registered users and 6 million SKUs, run by one person, on a 2-core Dedicated instance. Its founder told us the app made less than $10,000 in its first year; in September 2026, it took more than $70,000 in a single day.
Qili, a blue-collar staffing platform started by former Meituan regional managers. Its only developer launched the app in two months. The worker-facing app has 5,000 to 6,000 daily active users and runs on a 1-core Dedicated instance. Its developer, who had tried other platforms first, told us their backends were at least twice as slow at reading and writing data.
This follows from the decisions described above: real tables and indexes so the query planner can do its job, nested queries compiled into a single SQL statement, and business logic sitting right next to the data.
"How do you keep my data safe?" In three layers:
Every project has its own schema, so data from different projects is logically separated.
Every request goes through permission checks in the API layer, with row-level rules compiled straight into the SQL.
Projects that need physical isolation can use a Dedicated instance, or run in their own cloud account with BYOC.
Data safety has another dimension: if you ever want to leave, or something goes wrong on our end, you need to be able to get your data out. You can:
Export your data to Excel or CSV at any time, including images and other media.
Read all of it through your project's standard, documented GraphQL API, from any client.
Count on it being stored in real PostgreSQL tables, not in a proprietary format or a large JSON column. A table is a table and a field is a field, so it maps cleanly onto any relational database.
Momen's architecture has changed a lot since 2020. We went from generating code per project to one shared runtime, from one environment per project to shared servers, from Hasura to our own data API, and from a standalone AI service back into the main backend. Each time, existing projects were migrated without their owners having to do anything.
The approach has changed many times. The goal hasn't: to let non-technical founders build apps that are reliable, hold up under real traffic and keep getting maintained, without ever having to think about connection pools, transaction rollbacks, query planning or dependency upgrades. Every one of those changes happened because we realized the old way left complexity somewhere it didn't belong, either with our users or with our future selves. Complexity does not disappear; it only moves. The value of a platform lies in taking it on. The architecture will almost certainly keep changing, but that goal will not.
Learn more about Momen: https://momen.app