- name
- webiny-admin-architect
- description
- Admin-side architecture patterns for Webiny extensions. Use this skill when building frontend features with headless features (UseCase/Repository/Gateway), presentation features (Presenter/ViewModel/hooks/components), MobX-based presenters, RegisterFeature, and Admin BuildParams. Covers the admin/ directory structure for both features/ and presentation/ layers.
# Admin Architecture Patterns
## TL;DR
Admin extensions are React components that register headless features (business logic with no UI) and presentation features (MobX presenters, React hooks, components). Headless features live in `admin/features/` and follow **UseCase → Repository → Gateway** layering. Presentation features live in `admin/presentation/` and add a **Presenter** (MobX view model) layer on top. Both use `createFeature` and `createAbstraction` from `webiny/admin`.
**All features — both headless and presentation — MUST provide a `resolve` function** in `createFeature`. This is how the `useFeature` hook accesses resolved instances from the DI container. Without `resolve`, the feature cannot be consumed from React.
## Working Context
This skill applies to both **extension developers** (working in `extensions/`) and **core developers** (working in `packages/`). The architecture patterns are identical — only imports differ.
| | Extensions (`extensions/`) | Core (`packages/`) |
| ----------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Imports** | `webiny/admin`, `webiny/admin/ui`, `webiny/admin/cms/entry/editor` | `@webiny/app`, `@webiny/admin-ui`, `@webiny/app-headless-cms/...` |
| **Catalog paths** | Use the `Import:` path | Use the `Source:` path |
| **MobX wrapper** | `createReactiveComponent` from `webiny/admin` | `createReactiveComponent` from `@webiny/app-admin` (never import `observer` from `mobx-react-lite` directly — the only exception is `@webiny/app-admin` itself, which is the source of the re-export) |
| **Entry point** | React component as `export default` in a file targeted by `<Admin.Extension src={...}>` | React component registered by the package initializer |
Detect which context you're in by checking the file path: `extensions/` → extension mode, `packages/` → core mode.
## Admin Directory Structure
```
admin/
├── Extension.tsx # Admin entry point (React component)
├── features/ # Headless features (business logic, no UI)
│ └── EnableThing/
│ ├── abstractions.ts
│ ├── EnableThingUseCase.ts
│ ├── EnableThingRepository.ts
│ ├── EnableThingGateway.ts
│ └── feature.ts
└── presentation/ # Presentation layer (hooks, components, presenters)
└── CurrentThing/
├── abstractions.ts # Presenter + ViewModel interfaces
├── CurrentThingPresenter.ts # MobX-based view model
├── useCurrentThing.ts # React hook for consumers
├── feature.ts # createFeature registration
└── components/ # React UI components
```
## Admin Extension Entry Point
The Admin entry point is a React component that registers features, providers, UI decorators, and config:
```tsx
// src/admin/Extension.tsx
import React from "react";
import { AdminConfig, RegisterFeature } from "webiny/admin";
import { CurrentThingFeature } from "./presentation/CurrentThing/feature.js";
import { EnableThingFeature } from "./features/EnableThing/index.js";
import { CurrentThingProvider } from "./presentation/CurrentThing/CurrentThingProvider.js";
import { ThingListView } from "./presentation/ThingListView/ThingListView.js";
export const Extension = () => {
return (
<>
{/* Register headless features (use cases, repositories, gateways) */}
<RegisterFeature feature={EnableThingFeature} />
{/* Register presentation features (presenters, view models) */}
<RegisterFeature feature={CurrentThingFeature} />
{/* Providers and UI components */}
<CurrentThingProvider />
<ThingListView />
{/* Admin config (menus, routes, etc.) */}
<AdminConfig>{/* Menu items, route definitions, etc. */}</AdminConfig>
</>
);
};
```
## Headless Features (`features/`)
Headless features contain business logic with no UI — use cases, repositories, and gateways. They follow the same layering as API features but use `webiny/admin` imports.
### Abstractions
```ts
// src/admin/features/EnableThing/abstractions.ts
import { createAbstraction } from "webiny/admin";
export interface IEnableThingUseCase {
execute(id: string): Promise<void>;
}
export const EnableThingUseCase = createAbstraction<IEnableThingUseCase>("EnableThingUseCase");
export namespace EnableThingUseCase {
export type Interface = IEnableThingUseCase;
}
export interface IEnableThingRepository {
execute(id: string): Promise<void>;
}
export const EnableThingRepository =
createAbstraction<IEnableThingRepository>("EnableThingRepository");
export namespace EnableThingRepository {
export type Interface = IEnableThingRepository;
}
export interface IEnableThingGateway {
enableThing(id: string): Promise<boolean>;
}
export const EnableThingGateway = createAbstraction<IEnableThingGateway>("EnableThingGateway");
export namespace EnableThingGateway {
export type Interface = IEnableThingGateway;
}
```
### Feature Registration
Headless features **must** provide a `resolve` function that returns the resolved use case (or multiple exports). This is what makes the feature consumable via `useFeature()` in the presentation layer:
```ts
// src/admin/features/EnableThing/feature.ts
import { createFeature } from "webiny/admin";
import { EnableThingUseCase as UseCase } from "./abstractions.js";
import { EnableThingUseCase } from "./EnableThingUseCase.js";
import { EnableThingRepository } from "./EnableThingRepository.js";
import { EnableThingGateway } from "./EnableThingGateway.js";
export const EnableThingFeature = createFeature({
name: "EnableThing",
register(container) {
container.register(EnableThingUseCase);
container.register(EnableThingRepository).inSingletonScope();
container.register(EnableThingGateway).inSingletonScope();
},
resolve(container) {
return {
useCase: container.resolve(UseCase)
};
}
});
```
## The `useFeature` Hook — Bridging DI and React
`useFeature` is the standard way to access headless features from React. It resolves the feature from the DI container and returns whatever `resolve` returned:
```tsx
import { useFeature } from "webiny/admin";
import { EnableThingFeature } from "~/features/EnableThing/feature.js";
// In a presentation hook or component:
const { useCase } = useFeature(EnableThingFeature);
await useCase.execute(id);
```
Presentation hooks wrap `useFeature` to provide a clean, typed API to React components:
```tsx
// src/admin/presentation/EnableThing/useEnableThing.ts
import { useFeature } from "webiny/admin";
import { EnableThingFeature } from "~/features/EnableThing/feature.js";
export function useEnableThing() {
const { useCase } = useFeature(EnableThingFeature);
return {
enableThing: useCase.execute.bind(useCase)
};
}
```
### Common `useFeature` patterns
**Multiple features in one hook:**
```tsx
export function useAuthentication() {
const { useCase: logInUseCase } = useFeature(LogInFeature);
const { useCase: logOutUseCase } = useFeature(LogOutFeature);
return {
login: logInUseCase.execute.bind(logInUseCase),
logout: logOutUseCase.execute.bind(logOutUseCase)
};
}
```
**MobX reactive state synced to React:**
```tsx
export function useIdentity() {
const { identityContext } = useFeature(IdentityContextFeature);
const [identity, setIdentity] = useState(identityContext.getIdentity());
useEffect(() => {
return autorun(() => {
setIdentity(identityContext.getIdentity());
});
}, [identityContext]);
return { identity, isAuthenticated: identity.isAuthenticated };
}
```
## Presentation Features (`presentation/`)
Presentation features contain the UI layer — a **Presenter** (MobX view model) that produces a ViewModel for React, plus hooks and components. The Presenter typically depends on a **headless feature abstraction** (use case or service) for data and actions — it does NOT duplicate the repository/gateway layering.
### Typical Pattern: Presenter depends on a headless feature
The Presenter injects a headless feature's use case or service as a DI dependency:
```ts
// src/admin/presentation/CurrentThing/abstractions.ts
import { createAbstraction } from "webiny/admin";
import type { MyEntity } from "~/shared/MyEntity.js";
export interface ICurrentThingVm {
loading: boolean;
entity: MyEntity | undefined;
error: Error | undefined;
}
export interface ICurrentThingPresenter {
vm: ICurrentThingVm;
init(): void;
}
export const CurrentThingPresenter =
createAbstraction<ICurrentThingPresenter>("CurrentThingPresenter");
export namespace CurrentThingPresenter {
export type Interface = ICurrentThingPresenter;
export type ViewModel = ICurrentThingVm;
}
```
```ts
// src/admin/presentation/CurrentThing/feature.ts
import { createFeature } from "webiny/admin";
import { CurrentThingPresenter as PresenterAbstraction } from "./abstractions.js";
import { CurrentThingPresenter } from "./CurrentThingPresenter.js";
export const CurrentThingFeature = createFeature({
name: "CurrentThing",
register(container) {
container.register(CurrentThingPresenter);
},
resolve(container) {
return {
presenter: container.resolve(PresenterAbstraction)
};
}
});
```
The Presenter implementation injects the headless feature's abstraction (e.g., a use case):
```ts
// src/admin/presentation/CurrentThing/CurrentThingPresenter.ts
import { GetThingUseCase } from "~/features/GetThing/abstractions.js";
class CurrentThingPresenterImpl implements CurrentThingPresenter.Interface {
vm: CurrentThingPresenter.ViewModel = { loading: false, entity: undefined, error: undefined };
constructor(private getThingUseCase: GetThingUseCase.Interface) {}
async init() {
this.vm.loading = true;
const result = await this.getThingUseCase.execute();
// ... update vm
}
}
export default CurrentThingPresenter.createImplementation({
implementation: CurrentThingPresenterImpl,
dependencies: [GetThingUseCase]
});
```
### One-off Pattern: Presenter with its own repository/gateway
On rare occasions, when a presentation feature does not contain reusable business logic (no headless feature to depend on), the presentation feature can contain its own repository and gateway alongside the presenter:
```ts
// src/admin/presentation/Dashboard/feature.ts — one-off, no headless feature
import { createFeature } from "webiny/admin";
import { DashboardPresenter as PresenterAbstraction } from "./abstractions.js";
import { DashboardPresenter } from "./DashboardPresenter.js";
import { DashboardRepository } from "./DashboardRepository.js";
import { DashboardGateway } from "./DashboardGateway.js";
export const DashboardFeature = createFeature({
name: "Dashboard",
register(container) {
container.register(DashboardPresenter);
container.register(DashboardRepository).inSingletonScope();
container.register(DashboardGateway).inSingletonScope();
},
resolve(container) {
return {
presenter: container.resolve(PresenterAbstraction)
};
}
});
```
> **Prefer the typical pattern.** Only use the one-off pattern when the business logic is truly presentation-specific and will never be reused by other features.
## Observable Service Implementation
For long-lived services that hold observable state (e.g., project config, feature flags):
```ts
import { makeAutoObservable, runInAction } from "mobx";
import { WcpService as ServiceAbstraction, WcpGateway } from "./abstractions.js";
class WcpServiceImpl implements ServiceAbstraction.Interface {
private project: ILicense | null = null;
constructor(private gateway: WcpGateway.Interface) {
makeAutoObservable(this);
}
getProject(): ILicense {
return this.project;
}
async loadProject(): Promise<void> {
const data = await this.gateway.fetchProject();
runInAction(() => {
this.project = data;
});
}
}
export const WcpService = ServiceAbstraction.createImplementation({
implementation: WcpServiceImpl,
dependencies: [WcpGateway]
});
```
- Registered in **singleton scope** — long-lived, holds state
- Use `makeAutoObservable(this)` in the constructor
- Wrap async state mutations in `runInAction`
## Repository Implementation
Repositories own domain data and cache. They use MobX for reactivity:
```ts
import { makeAutoObservable, runInAction } from "mobx";
import {
NextjsConfigRepository as RepositoryAbstraction,
NextjsConfigGateway,
NextjsConfig
} from "./abstractions.js";
class NextjsConfigRepositoryImpl implements RepositoryAbstraction.Interface {
private config: NextjsConfig | undefined = undefined;
constructor(private gateway: NextjsConfigGateway.Interface) {
makeAutoObservable(this);
}
View on GitHub