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>
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), checksrc/types/first. - Export everything -- the barrel export in
index.tswill 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:
idis kebab-case and unique across all modulesroutestarts with/and does not collide with existing routesfeatureFlagfollows themodule.<id>conventionstorageNamespacefollows thearchitools.<id>conventionnavOrderdoes not conflict with other modules in the same category (checksrc/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
localStorageorIndexedDBdirectly. UseStorageServicefrom@/lib/storage. - Always scope storage through the
storageNamespacefrom 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
defaultexport (required byReact.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):
- Start the dev server:
pnpm dev - Verify the module appears in the sidebar under its category.
- Click the sidebar link. Verify the module loads and displays correctly.
- Open the browser Network tab. Confirm a separate chunk is fetched for the module.
- Test all CRUD operations. Verify localStorage keys are prefixed with
architools.registru-corespondenta.
Flag disabled:
- In
src/config/flags.ts, setenabled: falseformodule.registru-corespondenta(or use the env varNEXT_PUBLIC_FLAG_MODULE_REGISTRU_CORESPONDENTA=false). - Verify the module disappears from the sidebar.
- Navigate directly to
/registru-corespondenta. Verify you get a 404. - Open the Network tab. Confirm no chunk for the module is fetched anywhere in the application.
Error boundary:
- Temporarily add
throw new Error('test')to the root component's render. - Verify the error boundary catches it, displays the error UI, and the rest of the app remains functional.
- Click "Reincearca" and verify the module attempts to re-render.
- 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.tsis imported externally)
Configuration
config.tsexports a validModuleConfigidis kebab-case and uniqueroutestarts with/, is unique, and matches the App Router pathfeatureFlagmatches the key inflags.tsstorageNamespacefollowsarchitools.<id>convention and is uniquenavOrderdoes not conflict within its category
Registration
- Config imported and added to
moduleConfigsinsrc/config/modules.ts - Lazy component added to
lazyComponentsinsrc/lib/module-loader.ts - Feature flag added to
defaultFlagsinsrc/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 tostorageNamespace - No direct
localStorage/IndexedDBcalls - No imports from other modules (
@/modules/*other than own) - Root component is a
defaultexport - 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.