Files
ArchiTools/docs/architecture/MODULE-SYSTEM.md
Marius Tarau 4c46e8bcdd Initial commit: ArchiTools modular dashboard platform
Complete Next.js 16 application with 13 fully implemented modules:
Email Signature, Word XML Generator, Registratura, Dashboard,
Tag Manager, IT Inventory, Address Book, Password Vault,
Mini Utilities, Prompt Generator, Digital Signatures,
Word Templates, and AI Chat.

Includes core platform systems (module registry, feature flags,
storage abstraction, i18n, theming, auth stub, tagging),
16 technical documentation files, Docker deployment config,
and legacy HTML tool reference.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 12:50:25 +02:00

19 KiB

Module System

ArchiTools internal architecture reference -- module registry, loader, and lifecycle.


Overview

ArchiTools is composed of discrete modules, each representing a self-contained functional unit (e.g., Devize, Oferte, Pontaj). Modules are registered in a central registry, gated behind feature flags, lazily loaded at runtime, and isolated from one another. This design provides three guarantees:

  1. Zero bundle cost for disabled modules -- they are never imported.
  2. Runtime safety -- a crash in one module cannot propagate to another.
  3. Clear ownership boundaries -- each module owns its types, storage, and UI.

Module Definition Model

Every module exposes a config.ts at its root that exports a ModuleConfig object. This is the module's identity card -- it tells the platform everything it needs to know to register, gate, load, and display the module.

ModuleConfig Interface

// src/core/module-registry/types.ts

type ModuleCategory = 'operations' | 'generators' | 'management' | 'tools' | 'ai';
type Visibility = 'all' | 'admin' | 'internal';

interface ModuleConfig {
  id: string;                    // unique kebab-case identifier, e.g. "devize-generator"
  name: string;                  // Romanian display name, e.g. "Generator Devize"
  description: string;           // Romanian description shown in module catalog
  icon: string;                  // Lucide icon name, e.g. "file-spreadsheet"
  route: string;                 // base route path, e.g. "/devize"
  category: ModuleCategory;      // sidebar grouping
  featureFlag: string;           // corresponding key in the feature flag system
  visibility: Visibility;        // audience restriction
  version: string;               // semver, e.g. "1.0.0"
  dependencies?: string[];       // other module IDs this depends on
  storageNamespace: string;      // isolated localStorage/IndexedDB prefix
  navOrder: number;              // sidebar sort order within its category
  tags?: string[];               // capability tags for search/filtering
}

Field Contracts

Field Constraint
id Must be unique across all modules. Kebab-case. Used as the key in the registry map.
route Must start with /. Must not collide with another module's route.
featureFlag Must have a matching entry in src/config/flags.ts.
storageNamespace Must be unique. Convention: architools.[module-id].
navOrder Lower numbers appear first. Modules in the same category are sorted by this value.
dependencies If specified, the platform will refuse to load this module unless all listed module IDs are also enabled and loaded.

Example

// src/modules/devize-generator/config.ts

import { ModuleConfig } from '@/core/module-registry/types';

export const registraturaConfig: ModuleConfig = {
  id: 'registratura',
  name: 'Registratură',
  description: 'Registru de corespondență multi-firmă cu urmărire documente.',
  icon: 'book-open',
  route: '/registratura',
  category: 'operations',
  featureFlag: 'module.registratura',
  visibility: 'all',
  version: '1.0.0',
  dependencies: [],
  storageNamespace: 'architools.registratura',
  navOrder: 10,
  tags: ['registry', 'documents', 'correspondence'],
};

Module Directory Structure

Every module lives under src/modules/ and follows a strict directory convention:

src/modules/[module-name]/
  components/       # React components specific to this module
  hooks/            # Custom hooks encapsulating business logic
  services/         # Data services (CRUD, transformations) using the storage abstraction
  types.ts          # Module-specific TypeScript types and interfaces
  config.ts         # ModuleConfig export (the module's identity)
  index.ts          # Public API barrel export

Rules

  • components/ -- Only UI components consumed within this module. If a component is needed by multiple modules, it belongs in src/shared/components/common/.
  • hooks/ -- Business logic hooks. Must not contain UI rendering. Must not directly call localStorage or IndexedDB -- use the storage abstraction via services.
  • services/ -- All data access goes through services. Services consume the platform's storage abstraction layer (src/core/storage/) and must scope all keys under storageNamespace.
  • types.ts -- All TypeScript types that are internal to this module. Shared types go in src/core/*/types.ts.
  • config.ts -- Exports exactly one ModuleConfig object. No side effects.
  • index.ts -- The barrel export. This is the only file that other parts of the system (the registry, the loader) may import from. It must export config and the lazy-loadable root component.

Barrel Export Convention

// src/modules/registratura/index.ts

export { registraturaConfig as config } from './config';
export { default as RegistraturaModule } from './components/RegistraturaModule';

The root component (RegistraturaModule) is what gets lazy-loaded and rendered at the module's route.


Module Registry

The central registry lives at src/config/modules.ts. It imports every module's config and builds a lookup map.

// src/config/modules.ts

import { registraturaConfig } from '@/modules/registratura/config';
import { emailSignatureConfig } from '@/modules/email-signature/config';
import { wordXmlConfig } from '@/modules/word-xml/config';
import { promptGeneratorConfig } from '@/modules/prompt-generator/config';
// ... all module configs

import type { ModuleConfig } from '@/core/module-registry/types';

const moduleConfigs: ModuleConfig[] = [
  registraturaConfig,
  emailSignatureConfig,
  wordXmlConfig,
  promptGeneratorConfig,
  // ... add new modules here
];

/** Map of module ID -> ModuleConfig for O(1) lookups */
export const moduleRegistry: Map<string, ModuleConfig> = new Map(
  moduleConfigs.map((config) => [config.id, config])
);

/** All registered configs, sorted by category then navOrder */
export const allModules: ModuleConfig[] = [...moduleConfigs].sort((a, b) => {
  if (a.category !== b.category) return a.category.localeCompare(b.category);
  return a.navOrder - b.navOrder;
});

/** Get configs for a specific category */
export function getModulesByCategory(category: ModuleConfig['category']): ModuleConfig[] {
  return allModules.filter((m) => m.category === category);
}

/** Validate that all module IDs are unique. Called once at startup in dev mode. */
export function validateRegistry(): void {
  const ids = moduleConfigs.map((m) => m.id);
  const duplicates = ids.filter((id, i) => ids.indexOf(id) !== i);
  if (duplicates.length > 0) {
    throw new Error(`Duplicate module IDs detected: ${duplicates.join(', ')}`);
  }

  const routes = moduleConfigs.map((m) => m.route);
  const duplicateRoutes = routes.filter((r, i) => routes.indexOf(r) !== i);
  if (duplicateRoutes.length > 0) {
    throw new Error(`Duplicate module routes detected: ${duplicateRoutes.join(', ')}`);
  }
}

The registry is a static, build-time artifact. It does not perform any dynamic discovery -- every module must be explicitly imported. This is intentional: it guarantees that the dependency graph is fully visible to the bundler for tree-shaking and code-splitting.


Module Loader

The loader is responsible for conditionally importing and rendering a module based on its feature flag status.

Lazy Loading Strategy

Each module's root component is wrapped in React.lazy(). The import only fires when the component is actually rendered, which only happens when the feature flag is enabled.

// src/core/module-registry/loader.ts

import React, { Suspense } from 'react';
import type { ModuleConfig } from '@/core/module-registry/types';
import { ModuleErrorBoundary } from '@/shared/components/common/module-error-boundary';
import { ModuleSkeleton } from '@/shared/components/common/module-skeleton';

/** Registry of lazy component factories, keyed by module ID */
const lazyComponents: Record<string, React.LazyExoticComponent<React.ComponentType>> = {
  'registratura': React.lazy(() => import('@/modules/registratura')),
  'email-signature': React.lazy(() => import('@/modules/email-signature')),
  'word-xml': React.lazy(() => import('@/modules/word-xml')),
  'prompt-generator': React.lazy(() => import('@/modules/prompt-generator')),
  // ... add new modules here
};

interface ModuleLoaderProps {
  config: ModuleConfig;
}

export function ModuleLoader({ config }: ModuleLoaderProps) {
  const LazyComponent = lazyComponents[config.id];

  if (!LazyComponent) {
    return <ModuleNotFound moduleId={config.id} />;
  }

  return (
    <ModuleErrorBoundary moduleId={config.id} moduleName={config.name}>
      <Suspense fallback={<ModuleSkeleton moduleName={config.name} />}>
        <LazyComponent />
      </Suspense>
    </ModuleErrorBoundary>
  );
}

Key Mechanics

  1. React.lazy() accepts a function that returns a dynamic import(). The import is only executed the first time the component renders.
  2. Suspense shows a skeleton loader while the chunk downloads and parses.
  3. ModuleErrorBoundary catches any rendering error within the module and displays a localized error state without crashing the entire application.

Why Not Automatic Discovery?

We intentionally avoid filesystem-based auto-discovery (e.g., import.meta.glob) because:

  • It defeats tree-shaking: the bundler must include every discovered module.
  • It hides the dependency graph from code review.
  • It makes the build non-deterministic if modules are added/removed from the filesystem.

Explicit registration is a small amount of boilerplate that pays for itself in predictability.


Navigation Auto-Discovery

The sidebar reads directly from the module registry to build its navigation tree. No separate nav configuration file exists.

// src/components/navigation/Sidebar.tsx (simplified)

import { allModules } from '@/config/modules';
import { useFeatureFlag } from '@/core/feature-flags';
import { groupBy } from '@/shared/lib/utils';

const CATEGORY_LABELS: Record<string, string> = {
  operations: 'Operatiuni',
  generators: 'Generatoare',
  management: 'Management',
  tools: 'Instrumente',
  ai: 'AI & Automatizari',
};

export function Sidebar() {
  const grouped = groupBy(allModules, (m) => m.category);

  return (
    <nav>
      {Object.entries(CATEGORY_LABELS).map(([category, label]) => {
        const modules = grouped[category] ?? [];
        const visibleModules = modules.filter((m) => {
          const flagEnabled = useFeatureFlag(m.featureFlag);
          return flagEnabled;
        });

        if (visibleModules.length === 0) return null;

        return (
          <SidebarGroup key={category} label={label}>
            {visibleModules.map((m) => (
              <SidebarItem key={m.id} icon={m.icon} href={m.route}>
                {m.name}
              </SidebarItem>
            ))}
          </SidebarGroup>
        );
      })}
    </nav>
  );
}

This means:

  • Adding a module to the registry and enabling its flag automatically adds it to the sidebar.
  • Disabling a flag automatically removes the module from navigation.
  • navOrder controls position within each category group.
  • visibility can be checked against the current user's role for further filtering.

Module Isolation Rules

Modules must be isolated from one another. This is enforced by convention and code review.

What Is Allowed

From To Allowed?
Module A component Module A hook Yes
Module A service src/lib/storage/ Yes
Module A component src/components/shared/ Yes
Module A hook src/lib/* (platform utilities) Yes
Module A Module B (any file) No

What Is Not Allowed

  • Direct imports between modules. Module A must never import { something } from '@/modules/module-b/...'. If two modules need to share data, they do so through a shared service in src/lib/ or through an event bus.
  • Shared mutable state. No module may write to a global store that another module reads, unless that store is a platform-level shared service (e.g., user context, notification system).
  • Cross-module storage access. Each module's storageNamespace is its own territory. Module A must never read or write keys prefixed with Module B's namespace.

Enforcement

  • ESLint rule (planned): a custom no-restricted-imports pattern that forbids @/modules/*/ imports from within a different module directory.
  • Code review: any PR that introduces a cross-module import must be flagged.

Module Lifecycle

A module goes through the following stages from registration to active use:

1. Registration
   Module config is imported into src/config/modules.ts.
   Module appears in the registry map.

2. Feature Flag Check
   At render time, the navigation system and route handler check
   the module's featureFlag via useFeatureFlag(). If disabled,
   the module is invisible and its code is never loaded.

3. Lazy Load
   When the user navigates to the module's route (and the flag is
   enabled), React.lazy() triggers the dynamic import(). The
   bundler fetches the module's code chunk over the network.

4. Suspense Boundary
   While the chunk loads, the Suspense fallback (ModuleSkeleton)
   is displayed.

5. Error Boundary Setup
   The ModuleErrorBoundary wraps the module. If any error occurs
   during mount or subsequent renders, the boundary catches it.

6. Mount
   The module's root component mounts. It may initialize hooks,
   call services, and read from its storage namespace.

7. Storage Namespace Init
   On first use, the module's services initialize their storage
   keys under the module's storageNamespace prefix. This happens
   lazily -- no storage is allocated until the module actually
   writes data.

8. Active
   The module is fully interactive. It manages its own state
   and communicates with the platform only through shared services.

9. Unmount
   When the user navigates away, the module unmounts normally via
   React's unmount lifecycle. Cleanup happens in useEffect
   return functions as usual.

Diagram

Registration ──> Flag Check ──┬──> [disabled] ──> Not loaded, not visible
                              │
                              └──> [enabled] ──> Lazy Load ──> Suspense ──> Mount ──> Active
                                                    │
                                                    └──> [error] ──> Error Boundary UI

Error Boundaries

Every module is wrapped in a ModuleErrorBoundary. This is a React error boundary class component that:

  1. Catches JavaScript errors in the module's component tree.
  2. Logs the error with the module's ID for diagnostics.
  3. Renders a fallback UI with the module name and a retry button.
  4. Does not crash the shell (sidebar, header, other modules remain functional).
// src/components/ModuleErrorBoundary.tsx

import React from 'react';

interface Props {
  moduleId: string;
  moduleName: string;
  children: React.ReactNode;
}

interface State {
  hasError: boolean;
  error: Error | null;
}

export class ModuleErrorBoundary extends React.Component<Props, State> {
  state: State = { hasError: false, error: null };

  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    console.error(
      `[ModuleError] ${this.props.moduleId}:`,
      error,
      errorInfo.componentStack
    );
    // Future: send to error reporting service
  }

  handleRetry = () => {
    this.setState({ hasError: false, error: null });
  };

  render() {
    if (this.state.hasError) {
      return (
        <div className="flex flex-col items-center justify-center p-8 text-center">
          <h2 className="text-lg font-semibold text-destructive">
            Eroare in modulul &ldquo;{this.props.moduleName}&rdquo;
          </h2>
          <p className="mt-2 text-sm text-muted-foreground">
            A aparut o eroare neasteptata. Incercati din nou sau contactati echipa tehnica.
          </p>
          <button
            onClick={this.handleRetry}
            className="mt-4 rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground"
          >
            Reincearca
          </button>
        </div>
      );
    }

    return this.props.children;
  }
}

How to Add a New Module

Step-by-step checklist for adding a module to ArchiTools. For full code examples, see MODULE-DEVELOPMENT.md.

1. Create the directory structure

mkdir -p src/modules/[module-name]/{components,hooks,services}
touch src/modules/[module-name]/{types.ts,config.ts,index.ts}

2. Write config.ts

Export a ModuleConfig with a unique id, route, featureFlag, and storageNamespace.

3. Implement the module

Build types, services, hooks, and components inside the module directory.

4. Create index.ts barrel export

Export config and the default root component.

5. Register in src/config/modules.ts

Import the config and add it to the moduleConfigs array.

6. Register the lazy component in src/lib/module-loader.ts

Add a React.lazy(() => import(...)) entry keyed by the module ID.

7. Add a feature flag in src/config/flags.ts

Create a flag with the key matching the module's featureFlag field.

8. Create the route

Add src/app/(modules)/[module-name]/page.tsx that renders the ModuleLoader.

9. Test

  • Verify the module appears in the sidebar when the flag is enabled.
  • Verify the module is completely absent (no network requests for its chunk) when the flag is disabled.
  • Verify the error boundary catches a thrown error gracefully.
  • Verify storage operations use the correct namespace prefix.

Appendix: Module Category Descriptions

Category Key Intended Use
Operațiuni operations Day-to-day operational tools (registratura, password vault)
Generatoare generators Document/artifact generators (email signature, Word XML, Word templates)
Management management Registries and asset management (IT inventory, address book, digital signatures)
Instrumente tools Utility tools (mini utilities, tag manager)
AI & Automatizări ai AI-powered features (prompt generator, AI chat)