This project is an exploration of modern fullstack development by building and comparing interchangeable backend APIs and frontend SPAs using different technologies.
The core idea is to replicate the same REST API and frontend application across multiple tech stacks and make them fully interchangeable.
All backends expose identical endpoints, and all frontends consume the same APIs, enabling any frontend to work with any backend without modification.
This setup allows for:
- Comparing code structure, developer experience, and performance
- Experimenting with new technologies in a real-world scenario
- Understanding how to build scalable, interchangeable services
- Identify a solid and abstracted folders structure for building REST APIs
While PostgreSQL is a powerful and mature relational database, this project opts for MongoDB β a NoSQL, document-oriented database β for the following reasons:
- Natural Data Modeling: API responses and application data often involve deeply nested object structures. MongoDBβs document model aligns directly with this, reducing the need for complex relational mapping.
- Simplified Queries: In PostgreSQL, deeply related data requires multiple tables and joins (e.g., grandchild β child β parent). In MongoDB, such structures can often be embedded within a single collection, improving readability and performance.
- JSON Support in PostgreSQL is Limited: While PostgreSQL supports JSON columns, querying and manipulating nested JSON fields in SQL can become verbose and less intuitive. MongoDB was designed around JSON-style documents, making these operations more seamless.
For projects involving hierarchical data, flexible schemas, or nested documents, MongoDB provides a more natural and efficient approach.
Each backend implements the same logic, routes, and data models:
| Language | Web Framework | API Address |
|---|---|---|
| TypeScript | Express | http://localhost:5000/api |
| Python | FastAPI | http://localhost:5001/api |
| Go | Gin | http://localhost:5002/api |
| Rust | Axum | http://localhost:5003/api |
Each backend connects to a shared set of services (e.g., MongoDB, Redis).
Each frontend is a modern Single Page Application (SPA) built using popular frameworks:
| Framework | Dev Server URL | Env File Example |
|---|---|---|
| React | http://localhost:8000 | react.env |
| Vue | http://localhost:8001 | vue.env |
| Svelte | http://localhost:8002 | svelte.env |
| Angular | Not Implemented Yet | angular.env |
All frontends communicate with any backend through the same REST API, enabling a plug-and-play architecture.
To connect a frontend to a specific backend, update the corresponding .env file with the correct API URL.
For example, for React, update the VITE_BACKEND_URL in react.env like so:
VITE_BACKEND_URL=http://localhost:5000/api # Express APIor
VITE_BACKEND_URL=http://localhost:5001/api # FastAPIEach app (frontend/backend) lives in its own folder (e.g., /backend-express, /backend-fastapi /frontend-react, etc.) and is containerized using Docker.
Prefixes backend- and frontend- are used so that the folders of the backend apps are next to each others and the folders of the frontend apps are next to each others.
- MongoDB: A MongoDB replica set with
mongo1,mongo2, and amongo-setupcontainer to initialize the replica configuration. - Mongo Express: Web-based UI for browsing and managing MongoDB data.
- Fake GCS Server: A local emulator for Google Cloud Storage, using
fsouza/fake-gcs-server. - Redis: In-memory database used for caching and as a message broker.
- RedisInsight: Web UI for inspecting and managing Redis data.
This demo project features two models: Users and Places. Users can create accounts and add their favorite places to their profiles, while other users can browse and view these places.
Defining a clear folder structure is essential for building scalable applications. Poor folder organization can quickly lead to issues such as circular import errors.
Below is the folder structure shown from bottom to top (import order), used across all backend apps, independent of the programming language or web framework:
Reusable and abstracted modules. No imports from sibling modules are allowed, placing lib at the bottom of the import order.
The /lib folder may contain layers built on top of the web and data validation/serialization libraries used. By convention, an underscore suffix is used to distinguish the underlying library from the project-specific layer (e.g. pydantic_ and fastapi_ for Python, or zod_ and express_ for TypeScript).
- /framework_ β Layer on top of the web framework (e.g.
fastapi_,express_,gin_,axum_) - /serialization_ β Layer on top of the validation/serialization framework (e.g.
pydantic_,zod_,validator_,serde_) - /clients β Wrappers around third-party APIs or service interfaces (e.g. MongoDB, Redis, Google Cloud Storage, task scheduling)
- /utils β Generic helper functions (e.g. date/string formatting, file and I/O operations, encryption)
- /types β Reusable shared type definitions used across the project (e.g. pagination, file upload)
Config module used to setup environment variables and global parameters, placing config at the bottom just above lib in the import order
Stores static assets such as images or files that may be served by the backend or used for documentation or testing. It may also contain helper functions for loading these assets.
/static is considered near the bottom of the import order, just above lib and config.
Services used by the application, such as third-party APIs or databases. These modules import clients from lib and instantiate them.
The services folder is split into:
- instances β Contains instantiated services consumed by the API (e.g. databases, third-party APIs, task publishers)
- setup β Contains helpers to orchestrate connection and shutdown logic for the service instances
instances is placed near the bottom of the import order, while setup sits at the top, as it is intended to be used just before the application starts. This split helps avoid circular imports.
Contains the data layer. Well-thought-out data models are critical not only for development, but also for testing, seeding, and consistency across different backend implementations.
Each backend includes a models/ folder with several key subfolders:
We break down schema definitions based on their specific purpose in the application lifecycle: creation, seeding, reading, editing, querying...
Each model or object will have its own schema file.
The types of schemas defined are described below:
- Defines how the data is stored in the database.
- Includes all internal fields and references.
- Used for generating dummy data when seeding a test database.
- Can include reference fields to link documents across collections since raw examples does not have proper indexes prior to DB injection.
- Represents the structure of a new document before it's inserted.
- Typically excludes fields like IDs or reverse relationships.
- Defines the structure of the data received via
HTTP POSTto create a record. - May differ from the creation schema (e.g. an object has an
imageUrlfield stored but the user uploads an image file instead of sending theimageUrlstring).
- Describes partial updates to existing documents.
- Often allows optional fields for flexible updates.
- Defines the structure of the data received via
HTTP PUT. - May differ from the update schema (e.g. an object has an
imageUrlfield stored but the user uploads an image file instead of sending theimageUrlstring).
- Used when retrieving data from the database to return to clients.
- Sensitive or internal fields (e.g., passwords) are omitted for security.
- Used when returning search results in pages.
- Handles metadata like
totalCount,page,pageSize, anddata.
- Defines the format for complex queries with filters, sorting, paginating etc...
Depending on the programming language and available stack, an ORM or ODM (Object Document Mapper) may be used to interact with the MongoDB database.
The /collections folder contains the implementation of each model using the chosen ORM/ODM.
Each collection ties the schema definitions to the actual database logic, enabling CRUD operations, relationships, and additional behaviors.
For each collection, a corresponding CRUD object is created to encapsulate its business logicβhandling all Create, Read, Update, and Delete operations.
To promote structure and security, CRUD operations are organized into three layers:
*Documentmethods: Direct interaction with the ORM/database layer.- Main methods: High-level interface that uses schemas (e.g., converts
PostSchemaβCreateSchema). user*methods: Add authorization and access control logic. A user needs to be passed as parameter to check if allowed to perform the crud operation.
Below the operations breakdown
getDocument: Fetches a single raw ORM object by ID.get: Returns the object in aReadSchemaformat.userGet: Ensures the requesting user has access to the document.
fetchDocuments: Queries the DB with filters, projections, pagination, etc.fetch: Returns results in aPaginatedDataSchemaformat.userFetch: Restricts results to data the user has access to.
createDocument: Creates a new record using aCreateSchema, inside a transaction.create: Converts aPostSchemato aCreateSchema, then callscreateDocument.userCreate: Prevents unauthorized field assignment or object relations (e.g., assigning ownership improperly).
updateDocument: Updates an existing record using anUpdateSchema, inside a transaction.update: Converts aPutSchemato anUpdateSchema, then callsupdateDocument.userUpdate: Prevents illegal changes to relationships or protected fields.
deleteDocument: Deletes a record using a Mongo transaction.delete: Delete a record by id.userDelete: Ensures the user is authorized to delete the object.
This folder contains example documents for each model, along with utility methods to seed/dump the database for testing.
Each example is structured using the SeedSchema defined in the schemas/ folder.
In a SaaS application, the data is used to perform actions and deliver services for each userβthis is the role of the /core layer.
It contains the core business logic that defines how the application uses data to fulfill its purpose.
This project does not include a
/corefolder, as it is just a basic CRUD API.
The /background folder contains code responsible for executing background jobsβtasks that run outside the scope of an API request.
These jobs often manipulate data defined in /models and apply business logic from /core or /lib.
For this reason, /background should sit higher in the import order than the other modules.
Some models or core logic may trigger background jobs (e.g. updating an embedding vector after a CRUD operation). To avoid circular imports, publishers (functions that enqueue tasks) and handlers (functions that process tasks) are separated into two modules that do not import each other.
A model can import a publisher to trigger a task, while a handler can import the same model to process that task.
publishers and handlers may share common parameters (e.g. broker URLs, task names, queue names, execution order). A third setup module is used to store these shared parameters, which both publishers and handlers import.
crons is the fourth submodule of /background. It contains tasks that periodically trigger jobs by calling a publisher.
The import order within /background is as follows:
- crons
- publishers
- handlers
- setup
The /api folder contains all logic related to HTTP request handling, authentication, middleware, and API documentation.
It acts as the main entry point for routing requests to the appropriate backend logic and sits in the upper tier of the import order.
It includes the following subfolders:
Contains middleware functions that apply logic before or after route handling, including:
- Authentication & Authorization
- CORS policies
- Data validation
- Error handling
β οΈ Some frameworks (like FastAPI) use dependency injection for middleware-like behavior. However, the concept maps closely to route-level middleware in frameworks like Express or Gin.
Contains code responsible for setting up the Swagger UI
β οΈ Depending on the framework:
- FastAPI: Automatically generates Swagger UI from route and schema definitions.
- Express/Gin/Axum: May require manual setup using tools like
swagger-jsdoc, comments, or YAML/JSON files.
Defines the actual REST API endpoints for each resource / data model.
Each model exposes a standardized set of 6 CRUD endpoints, ensuring consistency across all backends:
| Method | Path | Purpose | Input Schema | Output Schema | CRUD Function |
|---|---|---|---|---|---|
| GET | /model-name/ |
Search with filters via query parameters | (Query Params) | PaginatedDataSchema | *Fetch() |
| POST | /model-name/query |
Search with filters via request body | SearchSchema | PaginatedDataSchema | *Fetch() |
| POST | /model-name/ |
Create a new record | PostSchema | ReadSchema | *Create() |
| GET | /model-name/:objectId |
Retrieve a single record by ID | β | ReadSchema | *Get() |
| PUT | /model-name/:objectId |
Update an existing record | PutSchema | ReadSchema | *Update() |
| DELETE | /model-name/:objectId |
Delete a record by ID | β | β | *Delete() |
Each route is tied to a corresponding method in the related CRUD module for consistent error handling and logic reuse.
In the REST philosophy, the GET verb is used to retrieve data, while POST is typically used to create resources.
A limitation of GET requests is that they do not support a request body, which restricts how advanced queries can be expressed. Instead, filtering must rely on query parameters:
/model-name?age=25&name=Slim
This simple syntax works for basic use cases, but it falls short when:
- Filtering by a range (e.g. age between 30 and 40)
- Matching substrings or patterns (e.g. names containing a keyword)
- Filtering on nested fields (e.g.
address.zipcode)
Using a JSON body in a POST request would solve this, but that would break REST principles. To address this, a more expressive query parameter syntax is used.
Each query parameter follows the pattern: field=operator:value. If no operator is given, eq (equals) is assumed. For nested fields, an alias may be used like addressZipcode or just zipcode to filter on the address.zipcode nested property.
The different operations to be used are inspired by MongoDBβs query language:
operations = {
eq: "$eq",
ne: "$ne",
gt: "$gt",
gte: "$gte",
lt: "$lt",
lte: "$lte",
in: "$in",
nin: "$nin",
regex: "$regex",
text: "$text",
}
For example, the following request:
/user?age=lte:40&age=gte:30&name=regex:Slim&zipcode=2040
Would return users
- whose age is between 30 and 40
- whose name contains
'Slim' - whose address has a
zipcode=2040
This will translate to the following mongoDB query
{
age: {
$lte: 40,
$gte: 30
},
name: {
$regex: "Slim"
},
"address.zipcode": {
$eq: 2040
}
}
A post processing of the query parameters to convert nested fields filter names may be needed (e.g. from
zipcodetoaddress.zipcode)
A common limitation of REST APIs is over-fetching β retrieving more data than needed. For example, you may only need a few fields, but the API returns the entire object. This increases the size of the HTTP response, leading to higher latency and unnecessary load on the backend server.
Another issue arises with GET request limitations. Most browsers and servers enforce a maximum URL length (e.g. 2,083 characters in Internet Explorer). For complex or deeply nested queries, this limit can be exceeded.
GraphQL addresses both problems by allowing clients to:
- Specify exactly what data they want
- Use a JSON body for flexible and complex queries
However, GraphQL comes with additional complexity, and in many cases, a well-designed REST API remains simpler and more approachable.
To bring some of GraphQL's flexibility into REST, this project introduces a POST /model-name/query endpoint.
It offers the same functionality as GET /model-name, but with more powerful querying capabilities, including:
- Advanced filters
- Nested fields
- Field projection
The following json body
{
"name": ["regex:Slim"],
"age": ["gte:30", "lte:40"],
"zipcode": [2040],
"page": 1,
"size": 100,
"sort": ["name"],
"fields": ["id", "name", "address.zipcode"]
}would generate this MongoDB query
db.users
.find(
{
name: { $regex: 'Slim' },
age: { $gte: 30, $lte: 40 },
'address.zipcode': { $eq: 2040 },
},
{
id: 1,
name: 1,
'address.zipcode': 1,
}
)
.sort({ name: 1 })
.skip(0)
.limit(100);Includes scripts for data migration, debugging, or manual testing.
This folder was initially named scripts. It was renamed to bin because Rust provides special support for executing code placed in this directory.
A single file responsible for starting the HTTP server and running the REST API (e.g. index.ts, app.py, app.go, main.rs).
Located at the top of the import order, it imports the endpoints defined in the /api folder, sets up application dependencies by calling /services/setup, and starts the server.
Contains unit tests and other automated tests used to validate the application logic.
/tests naturally sits at the top of the import order, allowing it to import all other modules.
Each framework has its own specifics and terminology, but a common structure can be identified.
Entry files that initialize the application (main) and define the root component (App):
- React β
main.tsx+App.tsx - Vue β
main.ts+App.vue - Svelte β
main.ts+App.svelte - Angular β
main.ts+app.component.ts(viaAppModule)
Top-level components that represent whole pages
Reusable UI components and layout building blocks. This convention is shared across all frameworks.
Holds application state management logic.
Contains general-purpose TypeScript utilities and framework specific logic such as hooks for React and composables for Vue.
Shared type definitions such as Enums, Interfaces, and reusable Types. Centralizes consumed data models and contracts.
Static files such as images, icons, and fonts.
This project uses Tailwind CSS with a custom naming system.
The goal is consistency, clarity, and avoiding clashes with Tailwindβs built-in keywords.
surface-* represents the main app background layer.
The term surface is preferred over background and bg to avoid naming collisions with Tailwind utilities and base CSS properties.
Variations:
surfaceβ page/app background (primary canvas)surface-altβ alternative/raised surfaces (e.g. cards)surface-onβ hover/focus/highlight used on top of the surface
--color-surface: var(--color-white);
--color-surface-alt: var(--color-stone-50);
--color-surface-on: var(--color-stone-100);panel-* represents the complementary surface layer β usually opposite in brightness to the main surface.
This allows for clear contrast zones, such as side panels, headers/footers, or sticky overlays.
surface/panel is conceptually similar to Bootstrapβs light/dark themes.
Variations:
panelβ primary complementary surfacepanel-altβ alternative/raised complementary surfacepanel-onβ hover/focus/highlight used on top of the panel
--color-panel: var(--color-stone-700);
--color-panel-alt: var(--color-stone-600);
--color-panel-on: var(--color-stone-500);pen-* methaphorically represents things written or drawn by a pen such as text, lines and borders.
The term avoids collisions with Tailwind utilities like text-* or border-*.
Variations:
penβ default text/ink colorpen-mutedβ secondary/less prominent textpen-rulerβ borders, dividers, or lines (as if drawn with a ruler)pen-inverseβ text/ink used on dark panels
--color-pen: var(--color-stone-700);
--color-pen-muted: var(--color-stone-500);
--color-pen-ruler: var(--color-stone-300);
--color-pen-inverse: var(--color-stone-50);These groups follow a similar convention to Bootstrapβs contextual colors.
They serve both theming (primary/secondary) and functional roles (success/warning/danger).
primary-*andsecondary-*β define the main theme colors of the dashboard along withsurface-*andpanel-*.success-*,warning-*,danger-*β used for conveying functional meaning (feedback, alerts, validation).- Each group provides consistent variations:
-onβ used for hover, focus, or active states-surfaceβ inverted version, aligned with the mainsurfacebrightness
--color-primary: var(--color-sky-400);
--color-primary-on: var(--color-sky-600);
--color-primary-surface: var(--color-sky-50);
--color-secondary: var(--color-pink-500);
--color-secondary-on: var(--color-pink-600);
--color-secondary-surface: var(--color-pink-50);
--color-success: var(--color-teal-500);
--color-success-on: var(--color-teal-600);
--color-success-surface: var(--color-teal-50);
--color-warning: var(--color-orange-500);
--color-warning-on: var(--color-orange-600);
--color-warning-surface: var(--color-orange-50);
--color-danger: var(--color-red-500);
--color-danger-on: var(--color-red-600);
--color-danger-surface: var(--color-red-50);The disabled-* group defines styles for inactive or disabled form inputs.
It ensures consistency across backgrounds, text, and borders.
Variations:
disabled-surfaceβ background of a disabled inputdisabled-penβ text color of a disabled inputdisabled-rulerβ border/outline color of a disabled input
--color-disabled-surface: var(--color-gray-300);
--color-disabled-pen: var(--color-gray-500);
--color-disabled-ruler: var(--color-gray-300);The backdrop color is used for overlay layers behind modals, dialogs, or drawers.
It helps separate focus areas from the rest of the UI.
--color-backdrop: var(--color-stone-300);- Add the Rust/Axum backend (ongoing)
- Add an Angular SPA frontend