Jedes interne Admin-Tool beginnt sauber. Ein Screen listet Artikel: eine Tabelle, ein paar Kennzahlen-Karten oben, ein Status-Pill. Jemand schreibt ihn an einem Nachmittag, und er ist gut.
Dann kommt der Pages-Screen. Er ist fast derselbe — eine Tabelle, Kennzahlen-Karten, diesmal kein Status-Pill. Also wird er kopiert und zurechtgestutzt. Dann ein Users-Screen, ein Media-Screen, ein Audit-Log-Screen. Sechs Monate später ist das Tool ein Friedhof aus fast identischen Tabellen-Komponenten, jede mit ihrer eigenen, subtil abweichenden Pagination, ihrem eigenen Ladespinner, ihrer eigenen Vorstellung davon, wie ein Empty State aussieht. Einen Bug zu fixen heißt, ihn fünfmal zu fixen — und einen übersehen Sie.
Der Reflex ist, zu einer einzigen Mega-Komponente zu greifen — einer <SuperTable> mit vierzig Props und einem variant-Enum — und jeden Screen durch sie zu leiten. Das tauscht ein Copy-Paste-Problem gegen ein God-Object-Problem. Die Komponente bekommt jedes Mal ein neues Prop, wenn ein Screen etwas leicht anderes braucht, und irgendwann kann niemand sie mehr ändern, ohne einen Aufrufer drei Domänen weiter zu brechen.
Der bessere Zug ist, zu ändern, was ein Screen ist. Keine Komponente, die man konfiguriert, sondern Daten, die man deklariert.
Ein Screen ist ein Descriptor
Die Einheit, für die ich mich entschieden habe, ist eine schlichte Beschreibung des Screens — seine Spalten und die Zahlen, die er zusammenfasst — ganz ohne Rendering-Logik darin:
export interface ColumnDef<Row> {
field: string;
header: string;
flex?: number;
width?: number;
minWidth?: number;
sortable?: boolean;
type?: "date" | "number";
/** Derive the sortable/display value from the row. */
value?: (row: Row) => unknown;
/** Custom cell content, e.g. a status pill. */
render?: (row: Row) => ReactNode;
}
export interface StatCard<Row> {
label: string;
compute: (rows: Row[]) => number | string;
hint?: string;
}Das ist der gesamte Contract, den ein Screen-Autor anfasst. Alles darüber, wie eine Tabelle paginiert, sortiert, einen Ladebalken zeigt oder einen Fehler rendert, lebt woanders — in einer einzigen generischen Komponente, die die Domänen nie bearbeiten:
export function TablePage<Row extends GridValidRowModel>({
columns, rows, getRowId, loading, error, statCards, pageSize = 10,
}: TablePageProps<Row>) {
// maps ColumnDef -> the grid's native column shape, once
const gridColumns = useMemo<GridColDef<Row>[]>(
() => columns.map((c) => ({
field: c.field,
headerName: c.header,
flex: c.flex,
width: c.width,
minWidth: c.minWidth,
sortable: c.sortable ?? true,
type: c.type,
valueGetter: c.value ? (_v, row) => c.value!(row) : undefined,
renderCell: c.render ? (params) => c.render!(params.row) : undefined,
})),
[columns],
);
// ...renders the stat cards + the data grid
}Zwei kleine Übersetzungen leisten hier die eigentliche Arbeit. Das value einer Spalte wird zum valueGetter des Grids — die Funktion, die eine Zeile in einen sortier- und darstellbaren Zellenwert verwandelt. Das render einer Spalte wird zu renderCell — beliebiges JSX für die Zelle, und genau so kommt ein Status-Pill oder ein Link hinein, ohne dass das Framework irgendetwas über Status oder Links weiß. Die Domäne beschreibt die Absicht; das Framework besitzt die Mechanik der darunterliegenden Grid-Bibliothek. Tauschen Sie diese Bibliothek eines Tages aus, und kein Descriptor ändert sich.
Warum das eine DDD-Grenze ist, nicht bloß ein Helfer
Bei der Trennung geht es nicht nur darum, Duplikation zu vermeiden. Es ist eine im Dateibaum gezogene Domain-Driven-Design-Grenze — DDD in dem einen Sinne, der sich hier auszahlt: Jeder Teil des Produkts (articles, pages, users) ist ein Bounded Context, der in seinem eigenen Modul lebt, seine eigenen Regeln besitzt und nicht in einen anderen hineingreifen darf. Die geteilte Maschinerie lebt an einem Ort, von dem diese Module abhängen können, der aber von keinem von ihnen abhängt.
Auf der Festplatte sind das zwei Top-Level-Ordner:
src/
framework/ # wiederverwendbar, domänenagnostisch — kennt keine Domäne
bodies/
TablePage.tsx # der generische, Descriptor-getriebene List-Screen
features/ # Bounded Contexts (Umbenennung zu domains/ läuft)
articles/
ArticlesDataGrid.tsx # ein Descriptor, mehr nicht
StatusChip.tsx # artikelspezifischer Zelleninhalt
pages/
PagesTable.tsx # ein Descriptor, mehr nicht
utils/api/ # typisierter Client + Query-Hooks
Der Ordner heißt noch features/ — der übliche Next.js-Name — und ist auf dem Weg, in domains/ umbenannt zu werden, denn Bounded Context ist die ehrliche Bezeichnung für das, was dort lebt. Der Name zählt weniger als die Regel, die er kodiert: Nichts in articles/ darf aus pages/ importieren, und keines von beiden darf von framework/ importiert werden.
Das Diagramm in Worten: articles/ und pages/ deklarieren jeweils ihre Spalten gegen die TablePage von framework/ und hängen von deren ColumnDef / StatCard-Typen ab; framework/ hängt von keinem der beiden ab.
Der Abhängigkeitspfeil zeigt nur in eine Richtung. framework/ weiß nichts über articles oder pages — es kann nicht, und das wird dadurch erzwungen, dass es keine Imports aus den Domänen hat. Jede Domäne besitzt ihren eigenen Descriptor und ihr eigenes Data-Fetching und hängt nach innen von den Typen des Frameworks ab. Eine neue Domäne kann sich nicht versehentlich über die geteilte Tabelle an eine andere Domäne koppeln, weil die geteilte Tabelle keine Domäne enthält, an die sie sich koppeln könnte.
Das macht „data-driven" zu mehr als einem Slogan. Die Domänen-Schicht deklariert, was ein Screen ist; die Framework-Schicht entscheidet, wie er rendert. Die Grenze dazwischen ist eine Menge halbwegs serialisierbarer Descriptoren, kein Call-Graph.
So sieht ein Domänen-Screen tatsächlich aus, sobald das Gerüst existiert — die gesamte Artikel-Liste:
const columns: ColumnDef<Article>[] = [
{ field: "title", header: "Title", flex: 2, minWidth: 240 },
{ field: "category", header: "Category", flex: 1, minWidth: 150 },
{ field: "author", header: "Author", flex: 1, value: (a) => a.author?.name ?? "—" },
{ field: "updatedAt", header: "Updated", width: 130, type: "date",
value: (a) => (a.updatedAt ? new Date(a.updatedAt) : null) },
{ field: "status", header: "Status", width: 140, sortable: false,
render: (a) => <StatusChip status={a.status} /> },
];
const statCards: StatCard<Article>[] = [
{ label: "Total", compute: (rows) => rows.length },
{ label: "Published", compute: (rows) => rows.filter((a) => a.status === "PUBLISHED").length },
];Kein JSX für das Grid. Keine Pagination. Kein Loading-State. Der Screen ist eine Liste von Spalten und zwei Reducer über den Zeilen. Alles andere wird geerbt.
Der Test: ein Zero-Diff-Refactor
Jetzt kommt der Teil, der Ihnen sagt, ob die Grenze an der richtigen Stelle liegt.
Ich habe nicht zuerst das Gerüst gebaut und dann Screens darauf. Ich hatte eine funktionierende, handgeschriebene Artikel-Tabelle, die bereits fertig war und Live-Daten renderte. Um die Abstraktion zu validieren, habe ich den bestehenden Screen so umgeschrieben, dass er durch sie rendert — und auf Veränderungen geachtet.
Der Beweis, dass Sie die Grenze richtig gezogen haben, ist: Einen bestehenden Screen durch die Abstraktion zu refactoren erzeugt null sichtbaren Diff. Ändert sich der Screen, drängt die Abstraktion ihm Meinungen auf, um die er nicht gebeten hat.
Die Artikel-Tabelle sah davor und danach identisch aus und verhielt sich identisch — dieselben Spalten, dieselben Zählungen, dieselben Status-Pills, dasselbe Sortierverhalten. Das ist keine Enttäuschung; es ist das ganze Ergebnis. Ein Refactor, der nichts ändert, was der Nutzer sieht, während er eine handgeschriebene Komponente auf einen zehnzeiligen Descriptor eindampft, ist das Signal, dass das Gerüst genau die Struktur des Screens erfasst hat und nichts von seinem beiläufigen Detail.
Dann kostete der zweite Screen — pages — einen Descriptor und sonst nichts. Hier ist das Ganze — dieselbe TablePage, gefüttert mit einem anderen Descriptor:
const columns: ColumnDef<Page>[] = [
{ field: "title", header: "Title", flex: 2, minWidth: 240 },
{ field: "slug", header: "Route", flex: 1, minWidth: 160 },
{ field: "updatedAt", header: "Updated", width: 130, type: "date",
value: (p) => (p.updatedAt ? new Date(p.updatedAt) : null) },
];
const statCards: StatCard<Page>[] = [{ label: "Total", compute: (rows) => rows.length }];Stellen Sie ihn neben den Artikel-Descriptor oben, und der gesamte Unterschied zwischen den beiden Screens ist der Unterschied in ihren Daten: pages lässt die Status-Spalte weg — sie haben keinen Lifecycle-Status — und behält eine Kennzahlen-Karte statt zwei. Keine neue Komponente, kein zweites Grid, keine fünfte Neuimplementierung von Pagination. Das Gerüst war bereits geschrieben.
Die Daten tippfehlersicher machen
Es gibt einen naheliegenden Einwand dagegen, Screens als Daten zu deklarieren: Ein Descriptor ist nur ein Objektliteral, und ein Objektliteral ist genau der Ort, an dem sich Tippfehler verstecken. Genau das ist mir passiert. Eine pages-Spalte lautete:
{ field: "routeSlug", header: "Route" } // the real key is `slug`Es kompilierte, es lief, und die Spalte war für jede Zeile leer — das Grid schlug row["routeSlug"] nach, bekam undefined und renderte nichts. Kein Fehler, kein Crash. Ein stiller Bug, der wie ein Backend-Problem aussieht.
Die Lösung ist, die Keys des Descriptors dem Row-Typ unterzuordnen. Statt field: string binden Sie es an die eigenen Property-Namen der Zeile:
// the one line that changes in ColumnDef<Row>:
field: Extract<keyof Row, string>; // was: field: stringJetzt ist "routeSlug" kein gültiges field — der Compiler weist es zurück und listet die Keys auf, die gültig sind. Der Tippfehler wird zum Build-Fehler, nicht zum Runtime-Rätsel. Spalten, die tatsächlich keine hinterlegte Property haben — ein Actions-Button, eine Zeilennummer — steigen über eine eigene Variante aus, sodass „kein field" eine sichtbare Entscheidung ist, nie ein Versehen.
Data-driven zahlt sich nur aus, wenn die Daten so streng typisiert sind, wie es Code wäre. Ein Descriptor, in dem Sie ungehindert Tippfehler machen können, ist nur eine Konfigurationsdatei, die darauf wartet zu verrotten.
Diese keyof-Bindung ist klein, aber sie ist es, die „Screens als Daten" das Recht verdient, dass man ihnen vertraut — und es sind dieselben generierten Row-Typen (direkt aus dem OpenAPI-Schema der API), die doppelte Arbeit leisten: Benennen Sie ein Feld im Backend um, und jeder veraltete Descriptor wird beim nächsten Codegen zum Compile-Fehler, statt zu einer leeren Spalte zur Laufzeit.
Die Disziplin: benennen, was man nicht abstrahiert hat
Der Fehlermodus jedes Frameworks ist, dass es nicht weiß, wann es aufhören soll. „Data-driven" rutscht in eine Konfigurationssprache ab, die das Host-Framework schlecht neu erfindet, und schon pflegen Sie ein schlechteres React innerhalb von React.
Die nützliche Hälfte dieser Arbeit ist also die Liste der Dinge, die ich bewusst konkret gelassen habe:
- Spaltenüberschriften sind schlichte Strings — obwohl das Lokalisierungs-Primitiv bereits im Code existiert (
LocalizedText, ein Wert, der entweder ein literaler String oder eine pro Locale aufgelöste Referenz auf eine Übersetzung ist). Überschriften dadurch zu leiten, ist eine einzeilige Typ-Änderung — ausheader: stringwirdheader: LocalizedText— eine, die ich an dem Tag machen würde, an dem eine zweite Sprache real wird, keine spekulative Schicht, die man an Tag eins einbaut. - Kennzahlen-Karten rechnen bei jedem Render neu, über die aktuell geladene Seite von Zeilen. Für ein internes Admin-Tool mit überschaubaren Datenmengen ist ein
filterüber ein paar hundert Zeilen pro Render kostenlos, und die Einfachheit ist mehr wert als ein memoisiertes Aggregat, das ich korrekt invalidieren müsste. Das ist eine bewusste Entscheidung für data-driven vor Performance, mit offenen Augen getroffen — und etwas, das man an dem Tag neu bewertet, an dem die Zahlen es verlangen, nicht früher. - Die Grid-Bibliothek scheint im Descriptor durch —
flex,minWidth. Ich habe mich entschieden, keine Layout-Abstraktion darüberzustülpen, weil eine undichte mehr kostet als die Ehrlichkeit, die eigenen Stellschrauben des zugrunde liegenden Grids beim Namen zu nennen.
Jede dieser Stellen ist ein Ort, an dem ein Purist weiter abstrahieren würde. Jede hätte eine Schicht hinzugefügt, die ich noch nicht mit einer echten Anforderung rechtfertigen kann. Die Grenze liegt nur dann an der richtigen Stelle, wenn Sie laut sagen können, wo sie endet.
Das Fazit zum Mitnehmen
Ein als Daten deklarierter Screen ist nicht automatisch besser als ein in Code geschriebener. Er ist besser, wenn Sie viele Screens haben, die eine Form teilen, und ein Team, das immer weitere hinzufügen wird — genau die Situation, in der ein internes Admin-Tool immer steckt, ob es am Anfang jemand zugegeben hat oder nicht.
Die zwei Dinge, die es wert sind, übernommen zu werden, unabhängig vom Stack: Ziehen Sie die Grenze als Einweg-Abhängigkeit — Domänen hängen nach innen von einem Framework ab, das nichts über sie weiß — und validieren Sie sie mit einem Zero-Diff-Refactor eines Screens, dem Sie bereits vertrauen. Ändert sich der Screen, haben Sie das Falsche abstrahiert. Ändert er sich nicht, haben Sie sich den nächsten Screen zum Preis eines Descriptors verdient.
Wenn Sie diese Grenze woanders gezogen haben — oder den ganzen Descriptor-Zug für eine Falle halten — würde ich wirklich gern hören, wo er bei Ihnen gebrochen ist. Die Tür steht offen unter cordata.tech/contact.