ミツモア Tech blog

「ミツモア」を運営する株式会社ミツモアの技術ブログです

Our Minimalist Architecture Codenamed V-NEXT

By @Baoshan Sheng, Product Designer at MeetsMore

After 1,200 days since the inception, our flagship service ProOne is receiving its first major architectural revamp.

💡 Before diving into the “Talk is cheap. Show me the code.” part, it's worth noting that software architecture is something to be felt; more precisely, to be felt while practicing our craftsmanship. At the same time, the feeling usually cannot be fully verbalized: it’s Phenomenology, it’s Zeitgeist.

Our codebase mainly consists two projects:

  1. a NestJS-based server project, and
  2. a NextJS-based web project

1. The V-NEXT Folders

Inside both projects, two new v-next folders are introduced:

proone
├── server/src/
│   └── v-next/
└── web/src/
    └── v-next/

Inside there, our adventure begins.

Note: While code outside v-next can import from v-next, the reverse is not allowed because the strictest TypeScript compiler options are enforced for v-next to be as defensive as possible.


2. Modularization

It will need a farmer, a builder, and a weaver, and also, I think, a shoemaker and one or two others to provide for our bodily needs. So that the minimum state would consist of four or five men.... —— Republic (Plato)

Modern enterprises operate on the same principle. Modern enterprise SaaS is no exception; it demands well-thought-out modularization.

Good modularization also enables code ownership. With our codebase surpassing 1M LoC (yes, insane!), if a developer is responsible for everything, then she/he is basically responsible for nothing.

V-PREV: The Status Quo

Currently there are HUNDREDS of folders in the root level of our projects:

proone/server/src
├── access-tokens
├── accounting-items
├── ... (200+ folders)
├── workers
└── workflows

proone/web/src/components/templates
├── AccountingDocumentHeading
├── AccountingDocumentLineTable
├── ... (500+ folders)
├── ValidationRule
└── Zendesk

Here are my findings:

  • Using NestJS doesn’t mean we have modularization for free.
  • We place these 500+ components under templates folder according to our coding conventions guideline but most of these components are referenced only once, so they are by no means templates.

Clearly, these are the opposite of good modularization.

V-NEXT: The Path Forward

To bring order to chaos, we went straight to the source—the product design. Together with our PMs, We crafted a list of around 30 modules which map directly to how our sales team sells, how our customer success team supports, and most importantly, how our clients think about their business.

In v-next, our product design is leading our way. No more developer-invented modules. The structure of the modules folders speak our business language:

proone/server/src/v-next/modules
├── auth
├── campaign-management
├── client-management
├── jobs-management
└── ... (less than 30 modules)

proone/web/src/v-next/modules
├── auth
├── campaign-management
├── client-management
├── jobs-management
└── ... (less than 30 modules)

Legacy modules will be migrated to v-next gradually.


3. Data Isolation

This section focuses primarily on backend architecture.

Without proper isolation, modular folders inside modules become merely cosmetic.

V-PREV: The Status Quo

Currently:

  1. We have a massive Prisma schema.prisma file spanning 7,000+ LoC.
  2. All data models are exposed to services of all modules.

With no data-level isolation, modules can freely access and modify each other's data stores without understanding the underlying data integrity requirements:

// inside module `contact`, developers can directly access models owned by module `client`
prisma.someModelOwnedByClientModule.update({ ... })

Such cross-module access inevitably leads to violations of our product design principles.

Note: While we use a relational database and try to maintain 3NF normalization at our best, many data integrity constraints still live in the application layer—which is quite normal (and acceptable) in practice.

V-NEXT: The Path Forward

In v-next, modules can't access data models from other modules. Each module maintains its own data models inside the prisma/schema folder, with each .prisma file being focused and concise:

server/prisma/schema
├── module-client.prisma  // v-next module
├── module-contact.prisma // v-next module
└── schema.prisma        // v-prev modules

For data models within the same module, we use foreign keys extensively to enforce referential integrity. However, we avoid foreign keys across modules; instead we use prefixed entity IDs (like USER:42), which provides more flexibility in modeling.

Note: Given (1) values of primary keys must be immutable, (2) our product relies on archiving/unarchiving instead of physical deletion, and (3) we still have NULL/NOT NULL constraints, the cost of avoiding cross-module foreign keys are acceptable.

This architecture enables polyglot persistence: when vertical scaling hits its limits, we can easily offload database load horizontally.


4. Inter-Module Communication

Without synergistic collaboration between modules, many features would be unusable (not to mention good UX).

V-PREV: The Status Quo

Not only do v-prev modules step on other modules’ data models via prisma, they also inject services from other modules (via DI) to call their methods.

Most injections lack proper design consideration. Our codebase has accumulated 340+ forwardRef() calls just to hack around circular dependencies (to make server bootable).

// 340+ forwardRef() in v-prev
@Module({ imports: [forwardRef(() => ContactModule)] })
class ClientModule {}

The inter-module contact surface is too large for developers to:

  • Understand the communication patterns between modules
  • Reason about the correctness of cross-module method calls
  • Simplify system design or effectively communicate with others
class ClientsService {
  constructor(
    @Inject(forwardRef(() => ContactsService))
    private readonly contactsService: ContactsService
  ) {}

  someMethod() {
    ...
    await this.contactsService.someOtherMethod(...)
    ...
  }
}

V-NEXT: The Path Forward

In v-next, modules can neither access data models nor services from other modules directly. Just as server-to-server or client-to-server communication over HTTP requires well-documented interfaces, modules need to be equally explicit about their inter-module communication contracts.

We rely on the @nestjs/cqrs module to provide three global message buses as the medium for inter-module communication. This makes contracts explicit and eliminates the need to inject services from other modules completely.

💡 CQRS enables more advanced patterns like event sourcing. But here, we are only using it as the medium for inter-module communication.

For synchronous inter-module communication, use the query or command buses. The query or command will be handled by the module in charge:

// inside client module
const result = await queryBus.execute(new SomeContactQuery()) // or
const result = await commandBus.execute(new SomeContactCommand())

For asynchronous inter-module communication, use the event bus. The event will be handled by the module(s) subscribing to the event:

// inside client module
eventBus.publish(new SomeContactEvent())

inter-module-communication.png


5. A New Perspective on Service Design

V-PREV: The Status Quo

Developers feel they are creating RESTful APIs, but they are actually creating ad-hoc endpoints across the HTTP boundary, accidentally:

  • Everything is highly coupled with UI (and most client-to-server communication patterns are quite arbitrary)
  • Many decorators are needed to enforce access-control, specify HTTP methods and there are many inconsistencies (bugs) like this:
@RbacResource(ResourceType.CLIENTS)
@UseGuards(CookieAuthGuard, RbacGuard)
@Controller('clients')
export class ClientsController {
  @Post()
  @RbacAccessType(AccessType.UPDATE) // !!! ERROR !!!
  async create(...) {
  }
}

V-NEXT: The Path Forward

We introduced a @Resource() decorator covering:

  • Authentication
  • Authorization (Role-Based Access Control)
  • Controller router prefix

Above example needs only one class-level decorator:

@Resource(ResourceType.CLIENTS)
export class ClientsController { ... }

v-next provides three categories of endpoint-level decorators:

  • CRUD decorators, combining HTTP methods, action (CREATE/LIST/READ/UPDATE/DELETE) based access control, and response (e.g., 201 { id: string }):
    • @List()
    • @Create()
    • @Read() and @ReadById()
    • @Update() and @UpdateById()
    • @Delete() and @DeleteById()
  • Standard views to power standard frontend components. Decorators in this category conform to standard responses (e.g., TableResponse, FormResponse, SelectResponse, and ItemResponse):
    • @Table() (powers <Table />)
    • @Form() (powers <Form />)
    • @Select() (powers <Select />)
    • @Item() (powers <Item />)
  • Standard actions:
    • @ArchiveById() and @UnarchiveById()

For example, if the frontend needs a table and a form for listing and creating clients, the controller would look like:

@Resource(ResourceType.CLIENTS)
export class ClientsController {
  @Create() create(...) { return ... }
  @Table() getTable() { return ... }
  @Form() getForm() { return ... }
}

Now we can see how the clean code aligns well with both the requirements for access control and user experience.


6. The Last Hammer for Auth System

Access control is very important for enterprise SaaS. We take this seriously at ProOne.

V-PREV: The Status Quo

We have a resource-based access control system which grants access to a request if one of the user's roles has the permission to perform some action(s) (e.g., READ) on some resource(s) (e.g., JOBS). It aligns with the permission matrix (image below) perfectly!

Screenshot 2024-12-20 at 4.50.57 AM.png

When it comes to supporting requirements like "a user can read a job assigned to them even if they don't have permission to READ JOBS, developers translated that into such logic: if an @AssignmentCheck() needAssignmentCheck parameter decorator is added to the controller method, and the user can READ a (virtual) resource type ASSIGNED_JOBS, access will be granted.

Note: The parameter can be discarded easily & immediately, requiring careful code review and leaving plenty of room for security holes.

When it comes to supporting requirements like "the owning division of a job/client/etc. determines who can see it", developers started using Prisma Extension to append queries to the where clause dynamically. This works to some extent but:

  • Injecting where clauses for nested queries is more complex (if not impossible)
  • In our current implementation, end-developers can easily opt-out of this step (intentionally or not), leaving plenty of room for security holes

“if all you have is a hammer, everything looks like a nail.”

In short:

  • The current RBAC system is polluted with business requirements that should not belong to it, the performance is not good
  • The current Prisma Extension is vendor lock-in, easy to bypass and more complex than the simplicity it deserves

V-NEXT: The Path Forward

Given a hypothetical requirement:

Gandalf the Wizard Can Kill His Mockingbird.

A table is presented here to align product design with technical implementation:

Requirements Product Design Time Point V-PREV V-NEXT
Gandalf Users management Authentication Session cookies JWT?
The Wizard Roles management Authorization B/E & DB DB only
Kill Permission matrix (x-axis) Authorization B/E & DB DB only
His Division-based access control Query Time B/E & DB DB only (RLS 🤩)
Mockingbird Permission matrix (y-axis) Authorization B/E & DB DB only

Among all the proposed changes, RLS (Row-Level Security) is the missing part of our existing auth system and makes the other parts more pure. With RLS, enforcing row-level security (indeed, no better term exists) becomes the responsibility of the database.

A minimalist context interface is provided by the authentication system:

class JobsController {
  @Table()
  getTable(@Context() context: Context) {
    context.prisma.jobs.findMany({ ... })
  }
}

Besides simple identifiers like tenantId and userId, the context object provides a prisma client with RLS enforced. Because this is the typical way for a typical service to get its prisma client (as cross-module service injection is prohibited, see Chapter 4), any attempt to bypass RLS will be quite obvious.


7. The Tale of Four Patterns

As an enterprise SaaS, ProOne targets various verticals of different sizes. We are building a one-stop solution so it’s feature rich and covers countless different activities modeled around the nature of our clients’ business.

V-PREV: The Status Quo

Product managers and designers have designed hundreds of pages. They share lots of commonalities and also have many differences (as expected). But neither the commonalities nor the differences are well verbalized.

💡 As a designer, I completely understand that many parts of our design processes are hard to be verbalized—it’s like falling in love with someone! But when handing product or design specifications to engineers, I prefer to leave zero room for interpretation. In other words, a verbalized design specification brings awareness to my design process and is very helpful in the long term. Similar concepts can be found in The Feynman Learning Technique.

In our codebase, we have literally:

  • Hundreds of forms built one-by-one manually
  • Hundreds of tables built one-by-one manually
  • Dozens to hundreds of dynamic single-or-multi selects built one-by-one manually
  • ...
// 304 occurrences of useForm
useForm<...>({...})

// 179 occurrences of DataGrid
<DataGrid ... />

“Are building these things, in this way, the daily job of engineers?” This is the first question I asked since I joined MeetsMore. Let me answer my own question.

V-NEXT: The Path Forward

Instead of creating reusable components for developers to pick to assemble a page like LEGO blocks, patterns (the terminology from Carbon Design) empower our PMs and designers to create consistent and ready-to-be-built UX.

Patterns are best practice solutions for how a user achieves a goal. They show reusable combinations of components and templates that address common user objectives with sequences and flows.

Four cornerstone server-driven React patterns are designed and engineered from bottom-up. They cover the most common and important interaction patterns:

  • <Form /> for creating, editing, and viewing single entry of data. Feature highlights:
    • The whole <Form /> is bootstrapped from a @Form() endpoint
    • Saves to the @Create() or @Update() endpoints
    • Nested <Table /> is supported to model complex array fields
    • Single-or-multi static-or-dynamic select to model referencing fields
  • <Table /> for viewing and editing multiple entries of data in a tabular style. Feature highlights:
    • The whole <Table /> is bootstrapped from a @Table() endpoint
    • Saves to the @Update() endpoints
    • Search bar, filtering criteria, and pagination supported
    • Row-level and table-level actions supported
    • Enhanced integration between DataGrid and React-Hook-Form by leveraging DataGrid’s native API
  • <Select /> for single-or-multi static-or-dynamic select:
    • The whole <Select /> is bootstrapped from an @Select() endpoint
    • Supports hierarchical options
    • Supports options with special formatting (double-line, name with avatar, etc.)
  • <Item /> for presenting a server-fed record in various styles. Feature highlights:
    • The whole <Item /> is bootstrapped from an @Item() endpoint
    • Both the content and the style are server-driven. We can render:
      • Multi-line items
      • Name with photo
      • Title with multiple chip-like tags, etc.

Bottom-up engineering towards end-user facing product-level patterns becomes our top secret of high usability:

  • Consistently applying these patterns makes our product self-explanatory
  • All pages get UX improvements to any of these four patterns for free

“A woman's whole life, in a single day, and then that day, her whole life.” This is the tale of the four patterns. This is how we believe tons can be condensed into grams.


8. Schema at The Center Stage

V-PREV: The Status Quo

Previously, when developers talked about validations, they either referred to (1) form validation or (2) validating request payloads before backend processing. With this in mind, they think schema libraries (like yup, zod, etc.) are only useful for such purposes.

This is not true.

V-NEXT: The Path Forward

In functional programming (also called value-oriented programming), the simultaneous evolution of values (including functions) with their types basically forms the whole program. Each step of the evolution can be seen as a parser that evolves the type and value at the same time.

💡 Read Parse, don’t Validate for the philosophy behind this approach.

Although we are not using Elm or PureScript, it is absolutely possible to let functional programming guard more aspects of our programs.

In v-next, we introduced Effect/Schema, which is an ideal match for this philosophy. With an Effect/Schema, we can:

  • Have a form schema centrally defined and then encode the schema into the definition (schema part) of a FormResponse (for @Form()) or a TableResponse (for @Table())
  • Have another schema that encodes a FormResponse into a React <Form /> pattern
  • etc.

Having schema in the central place, we will see less bugs due to the out-of-sync between code and data, or between different parts of the codebase.

To adopt functional programming effectively, developers’ brains need to be rewired. I won’t rush into this, but will definitely explore the opportunities to apply Effect/Schema and Effect (as the functional standard library) to more places.


That’s it for today! See you next time!