When Frontend Starts Thinking About Backend Architecture Part 4: Initial Planning for Frontend Permission Architecture
A record of initial frontend planning for permission architecture
February 24, 2026In the previous article When Frontend Starts Thinking About Backend Architecture Part 3, we mentioned that "data scope expansion" is not only about letting permissions reach more data, but also about giving permissions the ability to do "precise filtering." So from the frontend side, what should we pay attention to in architecture? For frontend topics, I like to imagine practical situations. Here is a hypothetical one.
PM π»: "We need to build an order management admin panel. Right now we have three roles: admin, operations manager, and customer support. Admin can do everything. Operations manager can view and delete orders, but only in Taipei and New Taipei areas. Customer support can only view orders."
The requirement above is very direct. What can a frontend engineer think about when assigned this task?
- Is permission data defined by frontend or backend?
- After we get the data, how should we plan the decision logic?
- How should components present different decision results?
Where does permission data come from?
In many cases, when the backend is not ready, frontend defines a possible data structure first so we can build an initial UI. At least in my first and second year, and even now, I still need to do this sometimes. So what frontend can do is try our best to understand how backend API response data is planned, and make it match most data structures.
But can permission data be written in frontend? Permission data belongs to business logic, and business logic must be defined and protected by backend.
At this point, frontend should ask backend: are permissions already defined? Is the API ready for integration? If not yet, the ideal process is that after PM proposes requirements, frontend and backend discuss API design together. At this stage, frontend can ask clearly about the data structure. After field names, data types, and response format are defined, we can use simple tools to record the result of the discussion. This document is called an API Contract.
Of course, if the company is small, then as much as possible we should clarify requirements with PM or the project owner.
How do we get data? Where do we put it after getting it?
For frontend, besides planning UI, the most important thing is API integration. If we have an API contract, frontend can write mock data.
Permission data is usually obtained after user login. There may be APIs like GET/me or GET /permissions. Backend returns the logged-in user's role and permission data.
In this example, returned data may look like this:
{
"user_id": "user123",
"role": "operation_manager",
"permissions": [
{
"resource": "order",
"actions": ["view", "delete"],
"scope": ["taipei", "newTaipei"]
}
]
}After frontend gets this permission data, what next? A common idea is to decide inside components. For example, we store permissions in a store, then use it in components that need permission checks.
const { user } = useAuthStore();
const canDelete =
user.role === "operation_manager" && ["taipei", "newTaipei"].includes(order.store);
return (
<div>
<div>Order details...</div>
{canDelete && <button>Delete</button>}
</div>
)This kind of decision works. But as requirements grow, things become harder to manage. We may have order page, order list, order details, order review, and so on. If similar components all use the style above, the same logic will be scattered across many pages. If requirements change one day, we must ensure logic in every component is updated.
At this point, pause and think: for frontend, what are the definition and purpose of UI? When we need interaction with users, does UI's definition change? For one screen, UI is still UI. What interacts with it is integrated data and business logic behind it. So we can separate:
- Presentation Layer
- Application Layer (Domain Layer)
- Data Layer
We call this Three-Layered Architecture
Presentation Layer
UI is our first layer, the Presentation Layer. It is responsible for UI and interaction with users, and does not handle business logic.
Applicaiton (Domain) Layer
It is responsible for business logic and manages decisions like "can delete or not" or "whether scope matches."
Data Layer
It is responsible for data access, only caring where data comes from and how it is transformed.
| Layer | Responsibility | Role in this example |
|---|---|---|
| Presentation Layer | Handles UI and user interaction, only cares about how the screen looks | Components (OrderPage, DeleteButton) |
| Domain Layer | Handles business logic and decides "can this action be done?" | PermissionService, Permission Entity |
| Data Layer | Handles data access, where data comes from and how it transforms | AuthRepository, PermissionMapper |
Although we describe starting from the presentation layer, implementation should start from the data layer, because presentation depends on domain, and domain depends on data from the data layer.
π οΈ Implementation
Now let's try planning with the Three-Layered Architecture concept.
Data Layer
The data layer is made of Repository and Mapper.
Mapper: translates backend language to frontend-friendly format. For example, if backend returnssnake_case,Mappercan convert it tocamelCase.Repository: a place dedicated to handling APIs. After calling API here, data can be transformed byMapper.
Before starting Mapper, we need to define data types first, also called Entity. Although Entity belongs to the application layer, we still need to know data shape first before we can start Mapper.
In an e-commerce case, the possible folder structure can follow Feature-based, like this:
src/
βββ features/
β βββ auth/
β β βββ components/
β β βββ services/
β β βββ repositories/
β β βββ entities/
β βββ order/
β βββ components/
β βββ services/
β βββ repositories/
β βββ entities/
βββ shared/
βββ components/
βββ utils/Intuitively, we may put the permission entity under auth. But order services also need it, and maybe other features too. So we can create shared/core to manage entities or services that are shared.
src/
βββ features/
β βββ auth/
β β βββ repositories/
β β β βββ AuthRepository.ts
β β βββ mappers/
β β βββ PermissionMapper.ts
β βββ order/
β βββ components/
β βββ OrderPage.vue
βββ shared/
βββ entities/
β β βββ Permission.ts β Shared Entity
βββ services/
βββ PermissionService.ts β Shared ServiceDefine Entity
// shared/entities/Permission.ts
export interface Permission {
resource: string
actions: string[]
scope: string[] | "all"
}
export interface UserPermission {
userId: string
role: string
permissions: Permission[]
}Define Mapper
Also, first check whether the team wants object-oriented encapsulation or function-oriented style. Code style and rules should be confirmed in the early stage of the project.
// features/auth/mappers/PermissionMapper.ts
const toPermissions = (p:any) => ({
resource: p.resource,
actions: p.actions,
scope: p.scope
})
const toDomainData = (apiResponse: any): UserPermission => ({
userId: apiResponse.user_id,
role: apiResponse.role,
permissions: apiResponse.permissions.map(toPermissions)
// If .map() does only one thing, and that thing is "pass the received item
// directly to the next function", you can skip the middle process:
// apiResponse.permissions.map((item) => toPermission(item))
})
export { toDomainData }If the original API response is:
{
"user_id": "12345",
"role": "admin",
"created_at": "2026-03-16",
"permissions": [
{
"resource": "article",
"actions": ["create", "read"],
"scope": "global",
"extra_data_we_dont_need": "xyz"
}
]
}After transformation, it becomes:
{
"userId": "12345",
"role": "admin",
"permissons": [
{
"resource": "article",
"actions": ["create", "read"],
"scope": "global"
}
]
}This involves a pattern: Tolerant Reader Pattern
It means: "only take fields I need, ignore fields I don't know or don't need."
Did you notice that
extra_data_we_dont_needdisappears after transformation?We will discuss it more deeply in the next article π
If one day backend accidentally changes user_id to userId, we only need to update this Mapper.
Define Repository
The only job of Repository is API management. It receives data from API integration, passes it to Mapper for transformation, and then delivers it upward layer by layer.
// features/auth/repositories/AuthRepository.ts
import { toDomainData } from '../mappers/PermissionMapper'
const createAuthRepository = (httpClient: typeof fetch) => {
return {
getMe: async(): Promise<UserPermission> => {
const res = await httpClient('/api/me')
const apiData = await res.json()
return toDomainData(apiData)
}
}
}
export { createAuthRepository }Application Layer (Domain Layer)
This layer also has two parts: Entity and Service. We already defined Entity, so only Service is left.
You can think of Service as an operations role. It manages all business logic for permissions. So the decision logic that was scattered in components should be collected here one by one.
// shared/services/PermissionService.ts
const createPermissionService = (userPermission: UserPermission) => {
return {
hasPermission: (action: string, resource: string, scope?: string): boolean => {
const permission = userPermission.permissions
.find((p) => p.resource === resource)
if (!permission) return false
const isActionAllowed = permission.actions.includes(action)
const isWithinScope =
permission.scope === "all" ||
(scope ? permission.scope.includes(scope) : true)
return isActionAllowed && isWithinScope
}
}
}
export { createPermissionService }Presentation Layer
This is the top layer and only handles visual display. It should not contain decision logic or data fetching. Here we will not discuss deeper usage like context or Custom Hook yet.
A possible way is to get data in OrderPage first.
import { useEffect, useState } from 'react'
import { createAuthRepository } from 'auth/repositories/AuthRepository'
import { creatOrderRepository } from 'order/respository/OrderRepository'
import { createPermissionService } from '../services/PermissionService'
import { OrderDetail } from './OrderDetail'
const authRepo = createAuthRepository(window.fetch)
// Not written above, but order-related APIs use the same approach:
// create a Repository to manage them.
const orderRepo = creatOrderRepository(window.fetch)
export default function OrderPage({ orderId }: { orderId: number }) {
const [permissionService, setPermissionService] = useState(null)
const [myOrder, setMyOrder] = useState(null)
useEffect(() => {
const init = async() => {
try {
const [authData, orderData] = await Promise.all([
authRepo.getMe(),
orderRepo.getOrderById(orderId)
])
setPermissionService(createPermissionService(authData));
setMyOrder(orderData);
} catch (error) {
console.error(error.message)
}
},
init()
}, [])
if (!permissionService) {
return (
<div>Loading...</div>
)
}
return (
<OrderDetail order={myOrder} permissionService={permissionService} />;
)
}Later we can discuss whether to make service into a custom hook. We can also discuss whether to use state management and data caching libraries to handle repository.
The current architecture works very well in this simple situation. But three months later, PM π» comes again:
"We need to adjust permissions dynamically. Rules are different for each client, and before deleting an order, we also need to check whether there is a related refund order..."
Isn't this a very common situation? Maybe three months is already the better case! π