Agent Skillswebiny/webiny-js › webiny-admin-architect

webiny-admin-architect

GitHub

提供 Webiny 后台扩展的架构规范,涵盖无头功能与展示功能的分层模式、目录结构及 DI 容器使用。

skills/user-skills/admin/admin-architect/SKILL.md webiny/webiny-js

Trigger Scenarios

构建 Webiny 管理后台前端功能 设计后台扩展的目录与模块结构 实现基于 MobX 和 React 的 UI 组件

Install

npx skills add webiny/webiny-js --skill webiny-admin-architect -g -y
More Options

Non-standard path

npx skills add https://github.com/webiny/webiny-js/tree/next/skills/user-skills/admin/admin-architect -g -y

Use without installing

npx skills use webiny/webiny-js@webiny-admin-architect

指定 Agent (Claude Code)

npx skills add webiny/webiny-js --skill webiny-admin-architect -a claude-code -g -y

安装 repo 全部 skill

npx skills add webiny/webiny-js --all -g -y

预览 repo 内 skill

npx skills add webiny/webiny-js --list

SKILL.md

Frontmatter
{
    "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:

// 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

// 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:

// 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:

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:

// 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:

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:

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:

// 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;
}
// 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):

// 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:

// 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):

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:

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);
  }

  getConfig(): NextjsConfig | undefined {
    return this.config;
  }

  async loadConfig(): Promise<void> {
    if (this.config) {
      return; // Already loaded — cache hit
    }

    const config = await this.gateway.getConfig();
    runInAction(() => {
      this.config = config;
    });
  }
}

export const NextjsConfigRepository = RepositoryAbstraction.createImplementation({
  implementation: NextjsConfigRepositoryImpl,
  dependencies: [NextjsConfigGateway]
});

Gateway Implementation (GraphQL)

Gateways handle external I/O. Use MainGraphQLClient for GraphQL calls:

import { NextjsConfigGateway as GatewayAbstraction } from "./abstractions.js";
import { MainGraphQLClient } from "webiny/admin";

const GET_NEXTJS_CONFIG = /* GraphQL */ `
  query GetNextjsConfig {
    websiteBuilder {
      getNextjsConfig {
        data
        error {
          code
          message
          data
        }
      }
    }
  }
`;

type GetNextjsConfigResponse = {
  websiteBuilder: {
    getNextjsConfig:
      | { data: string; error: null }
      | { data: null; error: { code: string; message: string; data: any } };
  };
};

class NextjsGraphQLGateway implements GatewayAbstraction.Interface {
  constructor(private client: MainGraphQLClient.Interface) {}

  async getConfig(): Promise<string> {
    const response = await this.client.execute<GetNextjsConfigResponse>({
      query: GET_NEXTJS_CONFIG
    });

    const envelope = response.websiteBuilder.getNextjsConfig;
    if (envelope.error) {
      throw new Error(envelope.error.message);
    }

    return envelope.data;
  }
}

export const NextjsConfigGateway = GatewayAbstraction.createImplementation({
  implementation: NextjsGraphQLGateway,
  dependencies: [MainGraphQLClient]
});

Key points:

  • Define the GraphQL query as a string constant with /* GraphQL */ comment for syntax highlighting
  • Type the response shape explicitly
  • Handle the data/error envelope pattern
  • Inject MainGraphQLClient from webiny/admin — it's preconfigured for the main GQL endpoint

Composite Features (Aggregating Child Features)

When grouping related features, create a composite with no resolve:

import { createFeature } from "webiny/admin";

export const FoldersFeature = createFeature({
  name: "Folders",
  register(container) {
    CreateFolderFeature.register(container);
    UpdateFolderFeature.register(container);
    DeleteFolderFeature.register(container);
  }
});

Extending Features (Decorators)

Decorators add cross-cutting concerns without modifying the core implementation:

class ListFoldersUseCaseWithLoading implements UseCaseAbstraction.Interface {
  constructor(
    private loadingRepository: FoldersLoadingRepository.Interface,
    private decoratee: UseCaseAbstraction.Interface // decoratee is LAST
  ) {}

  async execute() {
    await this.loadingRepository.runCallBack(this.decoratee.execute(), LoadingActionsEnum.list);
  }
}

Register with container.registerDecorator():

export const MyExtensionFeature = createFeature({
  name: "MyExtension",
  register(container) {
    container.registerDecorator(MyPresenterDecorator);
  }
});

Rules:

  • Implements the same interface as the decorated abstraction
  • Constructor: extra dependencies first, decoratee last
  • The dependencies array does NOT include the decoratee

Reading Admin BuildParams

There are two ways to read build parameters on the Admin side:

1. Via useBuildParams() hook — in React hooks and components

import { useBuildParams } from "webiny/admin/build-params";

const MyComponent = () => {
  const buildParams = useBuildParams();
  // Returns T | null — always handle null
  const dashboardUrl = buildParams.get<string>("DASHBOARD_URL");

  return dashboardUrl ? <a href={dashboardUrl}>Dashboard</a> : null;
};

2. Via BuildParams DI abstraction — in Presenters, Repositories, Gateways

When you need build params inside a DI-managed class (Presenter, Repository, etc.), inject BuildParams as a dependency:

import { BuildParams } from "webiny/admin/build-params";

class MyPresenterImpl implements MyPresenter.Interface {
  constructor(private buildParams: BuildParams.Interface) {}

  get vm() {
    const apiUrl = this.buildParams.get<string>("MY_API_URL");
    // ...
  }
}

export default MyPresenter.createImplementation({
  implementation: MyPresenterImpl,
  dependencies: [BuildParams]
});

Note: buildParams.get<T>() returns T | null — always handle the null case.

BuildParam declarations (<Admin.BuildParam>) live in the top-level extension component — see the webiny-full-stack-architect skill.

Core APIs

createAbstraction<T>(name: string)

Creates a typed DI token for admin-side abstractions.

Import import { createAbstraction } from "webiny/admin"
Returns Abstraction<T>

createFeature(def)

Creates a feature definition for the admin runtime.

Import import { createFeature } from "webiny/admin"
def.name Unique feature name (convention: "AppName/FeatureName")
def.register(container) Called at startup with the DI Container instance
def.resolve(container) Required. Resolves abstractions for useFeature() consumers

React Component Rules

  1. No && conditional rendering — never use condition && (<Component />) in JSX. For small one-two liners, use a ternary with explicit : null. For anything larger, extract to its own component file where the child decides whether to render.

Bad (never use &&):

{
  delivery.responseStatus !== null && <Text size="sm">HTTP {delivery.responseStatus}</Text>;
}

Good (small inline ternary):

{
  delivery.responseTime ? <Text size="sm">{delivery.responseTime}ms</Text> : null;
}

Good (larger block — own file, e.g., ResponseStatus.tsx):

export const ResponseStatus = ({ presenter }: Props) => {
  const { vm } = presenter;
  if (vm.responseStatus === null) {
    return null;
  }
  return <Text size="sm">HTTP {vm.responseStatus}</Text>;
};
  1. One component per file — every React component lives in its own file. No exceptions.

  2. React is a dumb view layer — components read presenter.vm and call presenter methods. No business logic, no data fetching, no state derivation in components.

  3. Pass presenter to child components — child components receive the full presenter, not split vm + actions props. The child reads what it needs from presenter.vm.

  4. Use createReactiveComponent for MobX-observed components — never import observer from mobx-react-lite directly. Use createReactiveComponent from webiny/admin (extensions) or from @webiny/app-admin (core packages). The only exception is @webiny/app-admin itself, which is the source of the re-export.

Bad:

import { observer } from "mobx-react-lite";
export const MyComponent = observer(({ presenter }: Props) => { ... });

Good:

import { createReactiveComponent } from "webiny/admin";
export const MyComponent = createReactiveComponent(({ presenter }: Props) => { ... });

Key Rules

  1. Abstractions first — any new business logic MUST be encapsulated in createAbstraction + createFeature.
  2. Namespace convention — every abstraction exports namespace MyAbstraction { export type Interface = ...; }.
  3. resolve is mandatory — every createFeature (headless or presentation) MUST provide a resolve function. This is how useFeature() accesses resolved instances from DI.
  4. Headless vs Presentation — business logic (use cases, repos, gateways) goes in features/; UI layer (presenters, hooks, components) goes in presentation/.
  5. Scoping — use cases = transient (default), repositories/gateways = singleton (.inSingletonScope()).
  6. useFeature is the bridge — presentation hooks call useFeature(SomeFeature) to get resolved exports, then wrap them for React consumption.
  7. Import extensions — always use .js extensions in import paths (ESM).

Related Skills

  • webiny-admin-permissions — Admin permission UI registration (Security.Permissions schema, custom UIs)
  • webiny-full-stack-architect — Top-level component, shared domain layer, BuildParam declarations
  • webiny-dependency-injection — The createImplementation DI pattern and injectable services
  • webiny-admin-ui-extensions — Admin UI customization, decorators, theming, forms, and config

Version History

  • 80eb1c5 Current 2026-08-20 10:06

Same Skill Collection

.claude/skills/grill-me/SKILL.md
.claude/skills/prd-to-plan/SKILL.md
.claude/skills/preflight/SKILL.md
.claude/skills/tester/SKILL.md
.claude/skills/write-a-prd/SKILL.md
skills/repo-skills/add-feature-flag/SKILL.md
skills/user-skills/admin/admin-permissions/SKILL.md
skills/user-skills/admin/form-model/SKILL.md
skills/user-skills/admin/new-entry-wizard/SKILL.md
skills/user-skills/admin/website-builder/page-settings/SKILL.md
skills/user-skills/admin/website-builder/wb-preview-url-modifier/SKILL.md
skills/user-skills/api-bundle-size-limit/SKILL.md
skills/user-skills/api/api-architect/SKILL.md
skills/user-skills/api/cms-bulk-actions/SKILL.md
skills/user-skills/api/custom-field-type/SKILL.md
skills/user-skills/api/event-handler-pattern/SKILL.md
skills/user-skills/api/graphql-api/SKILL.md
skills/user-skills/api/http-route/SKILL.md
skills/user-skills/api/permissions/SKILL.md
skills/user-skills/api/use-case-pattern/SKILL.md
skills/user-skills/api/v5-to-v6-migration/SKILL.md
skills/user-skills/api/websocket-notifications/SKILL.md
skills/user-skills/cli-extensions/SKILL.md
skills/user-skills/configure-auth0/SKILL.md
skills/user-skills/configure-entraid/SKILL.md
skills/user-skills/configure-okta/SKILL.md
skills/user-skills/dependency-injection/SKILL.md
skills/user-skills/full-stack-architect/SKILL.md
skills/user-skills/generated/api/aco/SKILL.md
skills/user-skills/generated/api/cms/SKILL.md
skills/user-skills/generated/api/file-manager/SKILL.md
skills/user-skills/generated/api/scheduler/SKILL.md
skills/user-skills/generated/api/security/SKILL.md
skills/user-skills/generated/api/system/SKILL.md
skills/user-skills/generated/api/tenancy/SKILL.md
skills/user-skills/generated/api/tenant-manager/SKILL.md
skills/user-skills/generated/api/website-builder/SKILL.md
skills/user-skills/generated/infra/SKILL.md
skills/user-skills/infrastructure-extensions/SKILL.md
skills/user-skills/local-development/SKILL.md
skills/user-skills/mailer-smtp/SKILL.md
skills/user-skills/project-structure/SKILL.md
.claude/skills/webiny-skill-creator/SKILL.md
skills/user-skills/admin/ui-extensions/SKILL.md
skills/user-skills/api/ai-powerups-content/SKILL.md
skills/user-skills/cognito-federation/SKILL.md
skills/user-skills/content-models/SKILL.md
skills/user-skills/generated/admin/aco/SKILL.md
skills/user-skills/generated/admin/ai-powerups/SKILL.md

Metadata

Files
0
Version
80eb1c5
Hash
b1ed0351
Indexed
2026-08-20 10:06

inicio - Wiki
Copyright © 2011-2026 iteam. Current version is 2.155.2. UTC+08:00, 2026-08-22 06:18
浙ICP备14020137号-1 $mapa de visitantes$