Files
ArchiTools/docs/guides/MODULE-DEVELOPMENT.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

23 KiB

Module Development Guide

Step-by-step guide for creating a new ArchiTools module. Read MODULE-SYSTEM.md and FEATURE-FLAGS.md first.


Prerequisites

  • Node.js 20+, pnpm
  • Familiarity with the module system architecture and feature flag system
  • A clear idea of your module's purpose, category, and naming

Walkthrough: Creating a New Module

This guide walks through creating a "Registru Corespondenta" (correspondence register) module as a concrete example. Adapt names and types to your use case.


Step 1: Create the Module Directory

mkdir -p src/modules/registru-corespondenta/{components,hooks,services}
touch src/modules/registru-corespondenta/{types.ts,config.ts,index.ts}

Result:

src/modules/registru-corespondenta/
  components/
  hooks/
  services/
  types.ts
  config.ts
  index.ts

Step 2: Define Types in types.ts

Define all TypeScript interfaces and types that are specific to this module. These types are internal to the module and must not be imported by other modules.

// src/modules/registru-corespondenta/types.ts

export interface Corespondenta {
  id: string;
  numar: string;                    // registration number
  data: string;                     // ISO date string
  expeditor: string;                // sender
  destinatar: string;               // recipient
  subiect: string;                  // subject
  tip: TipCorespondenta;
  status: StatusCorespondenta;
  fisiere: string[];                // attached file references
  note: string;
  creatLa: string;                  // ISO timestamp
  modificatLa: string;              // ISO timestamp
}

export type TipCorespondenta = 'intrare' | 'iesire' | 'intern';

export type StatusCorespondenta = 'inregistrat' | 'in-lucru' | 'finalizat' | 'arhivat';

export interface CorrespondentaFilters {
  tip?: TipCorespondenta;
  status?: StatusCorespondenta;
  startDate?: string;
  endDate?: string;
  searchTerm?: string;
}

Guidelines:

  • Use Romanian names for domain types to match business terminology.
  • Keep types self-contained. If you need a shared type (e.g., DateRange), check src/types/ first.
  • Export everything -- the barrel export in index.ts will control what is actually public.

Step 3: Create config.ts with ModuleConfig

This is the module's identity. Every field matters.

// src/modules/registru-corespondenta/config.ts

import type { ModuleConfig } from '@/types/modules';

export const registruCorrespondentaConfig: ModuleConfig = {
  id: 'registru-corespondenta',
  name: 'Registru Corespondenta',
  description: 'Evidenta si gestionarea corespondentei primite si expediate.',
  icon: 'mail',
  route: '/registru-corespondenta',
  category: 'operations',
  featureFlag: 'module.registru-corespondenta',
  visibility: 'all',
  version: '1.0.0',
  dependencies: [],
  storageNamespace: 'architools.registru-corespondenta',
  navOrder: 30,
  tags: ['corespondenta', 'registru', 'documente'],
};

Checklist for config values:

  • id is kebab-case and unique across all modules
  • route starts with / and does not collide with existing routes
  • featureFlag follows the module.<id> convention
  • storageNamespace follows the architools.<id> convention
  • navOrder does not conflict with other modules in the same category (check src/config/modules.ts)

Step 4: Implement the Storage Service

Services handle all data persistence. They use the platform's storage abstraction layer and scope all keys under the module's storageNamespace.

// src/modules/registru-corespondenta/services/corespondenta-storage.ts

import { StorageService } from '@/lib/storage';
import type { Corespondenta, CorrespondentaFilters } from '../types';
import { registruCorrespondentaConfig } from '../config';

const COLLECTION_KEY = 'entries';

const storage = new StorageService(registruCorrespondentaConfig.storageNamespace);

export const corespondentaStorage = {
  async getAll(): Promise<Corespondenta[]> {
    return storage.get<Corespondenta[]>(COLLECTION_KEY) ?? [];
  },

  async getById(id: string): Promise<Corespondenta | undefined> {
    const all = await this.getAll();
    return all.find((item) => item.id === id);
  },

  async save(item: Corespondenta): Promise<void> {
    const all = await this.getAll();
    const index = all.findIndex((existing) => existing.id === item.id);

    if (index >= 0) {
      all[index] = { ...item, modificatLa: new Date().toISOString() };
    } else {
      all.push({
        ...item,
        creatLa: new Date().toISOString(),
        modificatLa: new Date().toISOString(),
      });
    }

    await storage.set(COLLECTION_KEY, all);
  },

  async remove(id: string): Promise<void> {
    const all = await this.getAll();
    const filtered = all.filter((item) => item.id !== id);
    await storage.set(COLLECTION_KEY, filtered);
  },

  async filter(filters: CorrespondentaFilters): Promise<Corespondenta[]> {
    let results = await this.getAll();

    if (filters.tip) {
      results = results.filter((item) => item.tip === filters.tip);
    }
    if (filters.status) {
      results = results.filter((item) => item.status === filters.status);
    }
    if (filters.searchTerm) {
      const term = filters.searchTerm.toLowerCase();
      results = results.filter(
        (item) =>
          item.subiect.toLowerCase().includes(term) ||
          item.expeditor.toLowerCase().includes(term) ||
          item.destinatar.toLowerCase().includes(term)
      );
    }
    if (filters.startDate) {
      results = results.filter((item) => item.data >= filters.startDate!);
    }
    if (filters.endDate) {
      results = results.filter((item) => item.data <= filters.endDate!);
    }

    return results;
  },

  async getNextNumber(tip: Corespondenta['tip']): Promise<string> {
    const all = await this.getAll();
    const ofType = all.filter((item) => item.tip === tip);
    const year = new Date().getFullYear();
    const prefix = tip === 'intrare' ? 'I' : tip === 'iesire' ? 'E' : 'N';
    const nextSeq = ofType.length + 1;
    return `${prefix}-${year}-${String(nextSeq).padStart(4, '0')}`;
  },
};

Rules:

  • Never call localStorage or IndexedDB directly. Use StorageService from @/lib/storage.
  • Always scope storage through the storageNamespace from config.
  • Keep services pure data logic -- no React, no UI concerns.

Step 5: Write Hooks for Business Logic

Hooks bridge services and components. They encapsulate state management, loading states, error handling, and business rules.

// src/modules/registru-corespondenta/hooks/useCorespondenta.ts

'use client';

import { useState, useEffect, useCallback } from 'react';
import { corespondentaStorage } from '../services/corespondenta-storage';
import type { Corespondenta, CorrespondentaFilters } from '../types';

export function useCorespondenta(filters?: CorrespondentaFilters) {
  const [items, setItems] = useState<Corespondenta[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  const loadItems = useCallback(async () => {
    setIsLoading(true);
    setError(null);
    try {
      const data = filters
        ? await corespondentaStorage.filter(filters)
        : await corespondentaStorage.getAll();
      setItems(data);
    } catch (err) {
      setError(err instanceof Error ? err : new Error('Eroare la incarcarea datelor'));
    } finally {
      setIsLoading(false);
    }
  }, [filters]);

  useEffect(() => {
    loadItems();
  }, [loadItems]);

  const saveItem = useCallback(async (item: Corespondenta) => {
    await corespondentaStorage.save(item);
    await loadItems();
  }, [loadItems]);

  const removeItem = useCallback(async (id: string) => {
    await corespondentaStorage.remove(id);
    await loadItems();
  }, [loadItems]);

  return {
    items,
    isLoading,
    error,
    saveItem,
    removeItem,
    refresh: loadItems,
  };
}
// src/modules/registru-corespondenta/hooks/useCorrespondentaForm.ts

'use client';

import { useState, useCallback } from 'react';
import { corespondentaStorage } from '../services/corespondenta-storage';
import type { Corespondenta, TipCorespondenta } from '../types';
import { generateId } from '@/lib/utils';

export function useCorrespondentaForm(tip: TipCorespondenta) {
  const [isSaving, setIsSaving] = useState(false);

  const createNew = useCallback(async (
    data: Omit<Corespondenta, 'id' | 'numar' | 'creatLa' | 'modificatLa'>
  ): Promise<Corespondenta> => {
    setIsSaving(true);
    try {
      const numar = await corespondentaStorage.getNextNumber(tip);
      const item: Corespondenta = {
        ...data,
        id: generateId(),
        numar,
        creatLa: new Date().toISOString(),
        modificatLa: new Date().toISOString(),
      };
      await corespondentaStorage.save(item);
      return item;
    } finally {
      setIsSaving(false);
    }
  }, [tip]);

  return { createNew, isSaving };
}

Guidelines:

  • Hooks must be in the hooks/ directory.
  • Hooks must not import from other modules.
  • Hooks must not contain JSX or rendering logic.
  • Keep hooks focused -- one hook per concern (list, form, export, etc.).

Step 6: Build Components

Components live in components/ and compose hooks + shared UI components.

// src/modules/registru-corespondenta/components/CorrespondentaList.tsx

'use client';

import { useCorespondenta } from '../hooks/useCorespondenta';
import type { CorrespondentaFilters } from '../types';
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';

interface Props {
  filters?: CorrespondentaFilters;
  onSelect: (id: string) => void;
}

const STATUS_COLORS: Record<string, string> = {
  inregistrat: 'bg-blue-100 text-blue-800',
  'in-lucru': 'bg-yellow-100 text-yellow-800',
  finalizat: 'bg-green-100 text-green-800',
  arhivat: 'bg-gray-100 text-gray-800',
};

export function CorrespondentaList({ filters, onSelect }: Props) {
  const { items, isLoading, error } = useCorespondenta(filters);

  if (isLoading) {
    return (
      <div className="space-y-2">
        {Array.from({ length: 5 }).map((_, i) => (
          <Skeleton key={i} className="h-12 w-full" />
        ))}
      </div>
    );
  }

  if (error) {
    return (
      <p className="text-sm text-destructive">
        Eroare: {error.message}
      </p>
    );
  }

  if (items.length === 0) {
    return (
      <p className="text-sm text-muted-foreground">
        Nu exista inregistrari.
      </p>
    );
  }

  return (
    <Table>
      <TableHeader>
        <TableRow>
          <TableHead>Nr.</TableHead>
          <TableHead>Data</TableHead>
          <TableHead>Expeditor</TableHead>
          <TableHead>Subiect</TableHead>
          <TableHead>Status</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {items.map((item) => (
          <TableRow
            key={item.id}
            className="cursor-pointer"
            onClick={() => onSelect(item.id)}
          >
            <TableCell className="font-mono">{item.numar}</TableCell>
            <TableCell>{new Date(item.data).toLocaleDateString('ro-RO')}</TableCell>
            <TableCell>{item.expeditor}</TableCell>
            <TableCell>{item.subiect}</TableCell>
            <TableCell>
              <Badge className={STATUS_COLORS[item.status]}>
                {item.status}
              </Badge>
            </TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}

The root component ties everything together and serves as the module's entry point:

// src/modules/registru-corespondenta/components/RegistruCorrespondentaModule.tsx

'use client';

import { useState } from 'react';
import { CorrespondentaList } from './CorrespondentaList';
import type { CorrespondentaFilters } from '../types';

export default function RegistruCorrespondentaModule() {
  const [filters, setFilters] = useState<CorrespondentaFilters>({});
  const [selectedId, setSelectedId] = useState<string | null>(null);

  return (
    <div className="space-y-6">
      <div className="flex items-center justify-between">
        <h1 className="text-2xl font-bold">Registru Corespondenta</h1>
        {/* Add "Adauga" button here */}
      </div>

      {/* Filter bar component here */}

      <CorrespondentaList filters={filters} onSelect={setSelectedId} />

      {/* Detail/edit panel here */}
    </div>
  );
}

Rules:

  • The root component must be a default export (required by React.lazy).
  • Use shared UI components from @/components/ui/ (shadcn/ui).
  • Do not import components from other modules.
  • Mark all client-side components with 'use client'.

Step 7: Create the Barrel Export in index.ts

The barrel export defines the module's public API. The rest of the system may only import from this file.

// src/modules/registru-corespondenta/index.ts

export { registruCorrespondentaConfig as config } from './config';
export { default } from './components/RegistruCorrespondentaModule';

Important: The default export must be the root component. This is what React.lazy(() => import('@/modules/registru-corespondenta')) will resolve to.


Step 8: Register the Module in src/config/modules.ts

// src/config/modules.ts

import { registruCorrespondentaConfig } from '@/modules/registru-corespondenta/config';
// ... other imports

const moduleConfigs: ModuleConfig[] = [
  // ... existing modules
  registruCorrespondentaConfig,
];

Then add the lazy component entry in the module loader:

// src/lib/module-loader.ts

const lazyComponents: Record<string, React.LazyExoticComponent<React.ComponentType>> = {
  // ... existing entries
  'registru-corespondenta': React.lazy(() => import('@/modules/registru-corespondenta')),
};

Step 9: Add the Feature Flag in src/config/flags.ts

// src/config/flags.ts -- add to the defaultFlags array

{
  key: 'module.registru-corespondenta',
  enabled: true,
  label: 'Registru Corespondenta',
  description: 'Activeaza modulul de evidenta a corespondentei.',
  category: 'module',
  overridable: true,
},

The key must exactly match the featureFlag value in your module's config.ts.


Step 10: Add the Route

Create the Next.js App Router page for your module.

// src/app/(modules)/registru-corespondenta/page.tsx

'use client';

import { ModuleLoader } from '@/lib/module-loader';
import { moduleRegistry } from '@/config/modules';
import { useFeatureFlag } from '@/hooks/useFeatureFlag';
import { notFound } from 'next/navigation';

export default function RegistruCorrespondentaPage() {
  const config = moduleRegistry.get('registru-corespondenta');

  if (!config) {
    notFound();
  }

  const enabled = useFeatureFlag(config.featureFlag);

  if (!enabled) {
    notFound();
  }

  return <ModuleLoader config={config} />;
}

The route path must match the route field in your module config (minus the leading /).


Step 11: Test with Flag On/Off

Flag enabled (default):

  1. Start the dev server: pnpm dev
  2. Verify the module appears in the sidebar under its category.
  3. Click the sidebar link. Verify the module loads and displays correctly.
  4. Open the browser Network tab. Confirm a separate chunk is fetched for the module.
  5. Test all CRUD operations. Verify localStorage keys are prefixed with architools.registru-corespondenta.

Flag disabled:

  1. In src/config/flags.ts, set enabled: false for module.registru-corespondenta (or use the env var NEXT_PUBLIC_FLAG_MODULE_REGISTRU_CORESPONDENTA=false).
  2. Verify the module disappears from the sidebar.
  3. Navigate directly to /registru-corespondenta. Verify you get a 404.
  4. Open the Network tab. Confirm no chunk for the module is fetched anywhere in the application.

Error boundary:

  1. Temporarily add throw new Error('test') to the root component's render.
  2. Verify the error boundary catches it, displays the error UI, and the rest of the app remains functional.
  3. Click "Reincearca" and verify the module attempts to re-render.
  4. Remove the test error.

Module Checklist

Use this checklist before submitting a PR for a new module.

Structure

  • Module directory is at src/modules/<module-id>/
  • Directory contains: components/, hooks/, services/, types.ts, config.ts, index.ts
  • No files outside the module directory reference internal module files (only index.ts is imported externally)

Configuration

  • config.ts exports a valid ModuleConfig
  • id is kebab-case and unique
  • route starts with /, is unique, and matches the App Router path
  • featureFlag matches the key in flags.ts
  • storageNamespace follows architools.<id> convention and is unique
  • navOrder does not conflict within its category

Registration

  • Config imported and added to moduleConfigs in src/config/modules.ts
  • Lazy component added to lazyComponents in src/lib/module-loader.ts
  • Feature flag added to defaultFlags in src/config/flags.ts
  • Route page created at src/app/(modules)/<route>/page.tsx

Code Quality

  • All types defined in types.ts
  • Storage access goes through StorageService, scoped to storageNamespace
  • No direct localStorage/IndexedDB calls
  • No imports from other modules (@/modules/* other than own)
  • Root component is a default export
  • All client components have 'use client' directive
  • Hooks contain no JSX

Testing

  • Module loads correctly with flag enabled
  • Module is completely absent (no chunk, no nav item) with flag disabled
  • Direct URL navigation to disabled module returns 404
  • Error boundary catches thrown errors gracefully
  • Storage keys are correctly namespaced (inspect in DevTools > Application > Local Storage)
  • Module works after page refresh (data persists)

Anti-Patterns

1. Importing from another module

// WRONG
import { formatDeviz } from '@/modules/devize-generator/services/formatter';

// RIGHT: If you need shared logic, move it to src/lib/ or src/utils/
import { formatCurrency } from '@/lib/formatters';

Modules are isolated units. Cross-module imports create hidden coupling and break the ability to independently enable/disable modules.

2. Bypassing the storage abstraction

// WRONG
localStorage.setItem('registru-corespondenta.entries', JSON.stringify(data));

// RIGHT
const storage = new StorageService(config.storageNamespace);
await storage.set('entries', data);

Direct storage calls bypass namespace isolation, offline support, and future migration to IndexedDB or a remote backend.

3. Skipping the feature flag gate on the route

// WRONG: Module is always accessible via URL even when flag is disabled
export default function MyModulePage() {
  return <ModuleLoader config={config} />;
}

// RIGHT
export default function MyModulePage() {
  const enabled = useFeatureFlag(config.featureFlag);
  if (!enabled) notFound();
  return <ModuleLoader config={config} />;
}

Without the flag check on the route, a user can access a disabled module by typing the URL directly.

4. Putting business logic in components

// WRONG: Component directly manages storage and transforms data
function MyList() {
  const [items, setItems] = useState([]);
  useEffect(() => {
    const raw = localStorage.getItem('...');
    const parsed = JSON.parse(raw);
    const filtered = parsed.filter(/* complex logic */);
    setItems(filtered);
  }, []);
}

// RIGHT: Component delegates to a hook, which delegates to a service
function MyList() {
  const { items, isLoading } = useCorespondenta(filters);
}

Business logic in components makes code untestable, unreusable, and hard to maintain. Follow the layers: Component -> Hook -> Service -> Storage.

5. Non-unique identifiers

// WRONG: Reusing an ID or namespace that another module already uses
export const myConfig: ModuleConfig = {
  id: 'oferte',                    // already taken by the Oferte module
  storageNamespace: 'architools.oferte',  // already taken
};

This will cause data corruption and routing conflicts. The registry validation catches duplicate IDs, but namespace collisions are only caught by code review.

6. Side effects in config.ts

// WRONG: config.ts should be pure data, no side effects
console.log('Module loaded!');
await initializeDatabase();

export const config: ModuleConfig = { ... };

// RIGHT: config.ts exports only a plain object
export const config: ModuleConfig = { ... };

Config files are imported at registration time for all modules, regardless of whether they are enabled. Side effects in config files run unconditionally and defeat the purpose of lazy loading.

7. Exporting internal types from index.ts

// WRONG: Exposing internal implementation types to the outside
export { config } from './config';
export { default } from './components/MyModule';
export type { InternalState, PrivateHelper } from './types';  // leaking internals

// RIGHT: Only export what the platform needs
export { config } from './config';
export { default } from './components/MyModule';

The barrel export should be minimal. The only consumers are the module registry (needs config) and the module loader (needs the default component). Internal types stay internal.

8. Monolithic root component

// WRONG: One 500-line component that does everything
export default function MyModule() {
  // form logic, list logic, filter logic, modal logic, all inline
}

// RIGHT: Compose from focused sub-components
export default function MyModule() {
  return (
    <>
      <MyModuleHeader />
      <MyModuleFilters />
      <MyModuleList />
      <MyModuleDetailPanel />
    </>
  );
}

Break the module into focused components. The root component should primarily compose child components and manage top-level layout.