WebClerk Architecture

PJPV (WebClerk)

Pydantic JSON Path Value

The complete data behavior stack.
One schema defines type, format, label, value, and calculation.
One envelope carries it. One path resolves it.

45,091 1,759 lines of UI code

Five Behaviors, One Source

PJPV is not just about where data lives. One Pydantic schema provides five distinct behaviors. The component doesn't need to know what it's rendering — the schema tells it everything.

T

Typing

Decimal, int, str, bool, List[LineItem] — the field declares its type once. No TypeScript interface duplication. No runtime guessing.

F

Formatting

Currency, percentage, date format rules live in the schema. Display is a property of the field, not the component. Change the format once, every view updates.

L

Labels

Field(title="Balance") — the field names itself. No label strings scattered across JSX. Rename once in the schema, every UI element reflects it.

V

Values

JSON envelope is the source of truth. Scalars are indexes for queries. One engine computes. Display is a projection of JSON, never an input to it. Compute from data, display from computation, never the reverse.

C

Calculations

totals.margin = totals.total - totals.cost. Resolved through a path, computed by one engine. If two functions compute the same value, one is wrong.

Before & After

Detail Pages: Per-Model vs. Schema-Driven

Before — 45,091 lines
src/pages/
  InvoiceDetail.tsx      (1,847 lines)
  OrderDetail.tsx        (1,623 lines)
  ProposalDetail.tsx     (1,412 lines)
  PurchaseDetail.tsx     (1,398 lines)
  PaymentDetail.tsx      (  987 lines)
  ContactDetail.tsx      (2,103 lines)
  ItemDetail.tsx         (1,756 lines)
  ... 23 more models     (34,000 lines)
After — 1,759 lines
src/components/
  DynamicDetail.tsx      (  680 lines)
  LineCardRenderer.tsx   (  420 lines)
  TabsRenderer.tsx       (  340 lines)
  HeaderRenderer.tsx     (  319 lines)

// Every model uses the same components.
// Layout comes from Settings JSON.
// Field behavior comes from Pydantic schema.

Serializer: Flattening vs. Intact Envelope

Before
# Three serializers, three independent computations
def to_representation(self, instance):
    data = super().to_representation(instance)
    sell_total = Decimal(instance.sell.get('total', 0))
    cost_total = Decimal(instance.cost.get('total', 0))

    data['total_amount'] = str(sell_total)
    data['margin_amount'] = str(sell_total - cost_total)
    data['margin_percentage'] = str(
        (sell_total - cost_total) / sell_total * 100
        if sell_total else 0
    )
    return data
After
# Serializer passes envelope as-is
# totals.total, totals.margin, totals.margin_pc
# already computed by the one totals engine

class OrderSerializer(ModelSerializer):
    class Meta:
        fields = [..., 'totals', 'total', 'balance']
        read_only_fields = ['totals', 'total', 'balance']

    # No to_representation override needed

React: Local Computation vs. Path Resolution

Before
// Component computes its own totals
const subtotal = lines.reduce(
  (sum, item) => sum + (item.extended_price || 0), 0
);
const margin = lines.reduce(
  (sum, item) => sum + (item.line_margin || 0), 0
);
const total = lines.reduce(
  (sum, item) => sum + (item.extended_price || 0), 0
);

<span>Subtotal: ${subtotal.toFixed(2)}</span>
<span>Margin: ${margin.toFixed(2)}</span>
<span>Total: ${total.toFixed(2)}</span>
After
// Component reads from server envelope
<span>Subtotal: {fmt(data?.totals?.subtotal)}</span>
<span>Margin: {fmt(data?.totals?.margin)}</span>
<span>Total: {fmt(data?.totals?.total)}</span>

// Or via configured columns (any model)
{ accessorKey: 'totals.subtotal', format: 'currency' }
{ accessorKey: 'totals.margin',   format: 'currency' }
{ accessorKey: 'totals.total',    format: 'currency' }

How Others Do It

Every successful open-source commerce project passes nested JSON intact. None flatten in serializers. But none of them have Pydantic owning field behaviors end-to-end. The P is what makes PJPV different.

Project Nested JSON Server Totals Schema-Driven UI End-to-End Behavior
Saleor Yes Yes GraphQL fragments No
Medusa Yes decorateTotals No No
Invoice Ninja Yes Yes No No
Odoo Yes Yes OWL templates No
ERPNext Yes Yes DocType No
PJPV (WebClerk) Yes One engine Pydantic schema Yes

The Four Layers

PJPV is a four-layer architecture where Pydantic defines the schema, JSON carries the envelope intact, Path notation resolves each value, and the Value is authoritative — computed once by one engine, displayed everywhere.

P

Pydantic

Schema authority. Types, labels, formatting rules, validation, widget hints. The field knows what it is. The UI asks the schema, not the other way around.

J

JSON

Source of truth. The envelope travels intact from server to client. Serializers never flatten, extract, or reshape it. What Pydantic defined is what React receives.

P

Path

Resolution mechanism. Dot notation: totals.balance, price.extended. The path comes from configuration, not hardcoded in the component.

V

Value

Authoritative result. Computed once by one engine, never independently. If two functions compute the same value, one is wrong.

The Problem PJPV Solves

Serializer Flattening

Your API extracts total_amount from sell.total and serves it as a top-level field. The frontend learns to read the scalar. Now you have two sources of truth — the envelope and the scalar. When they disagree, nobody knows which is right.

Independent Computation

React components compute their own totals with .reduce() over line arrays. The server also computes totals. Three serializers compute margin independently — one uses Decimal, another uses float. They agree most of the time. When they don't, the user makes decisions on bad numbers.

Scattered Behavior

Labels in JSX. Format rules in utility functions. Type knowledge in TypeScript interfaces. Validation in Zod schemas. The same field is described in four places. Change one, forget the others. The drift is invisible until it surfaces as a bug.

Per-Model Detail Pages

Every model gets its own detail component. Invoice has InvoiceDetail. Order has OrderDetail. Each one knows its fields, formats them, labels them, computes them. 30 models × 1,500 lines = 45,000 lines of code that all do the same thing slightly differently.

How It Works

1

Pydantic Defines the Schema

Every field declares its type, label, and formatting in one place.

class InvoiceTotals(BaseModel):
    subtotal: Decimal = Field(title="Subtotal", ge=0)
    tax:      Decimal = Field(title="Tax", ge=0)
    total:    Decimal = Field(title="Total", ge=0)
    margin:   Decimal = Field(title="Margin")
    margin_pc: float  = Field(title="Margin %")
    received: Decimal = Field(title="Received", ge=0)
    balance:  Decimal = Field(title="Balance")
2

One Engine Computes Values

A single totals engine computes every value and writes it to the JSON envelope. No other code computes these values. Ever.

def recalculate_totals(transaction_id, model_name):
    header, lines = resolve(transaction_id, model_name)

    subtotal = sum(line.price.extended for line in lines)
    cost     = sum(line.cost.extended for line in lines)
    margin   = subtotal - cost
    margin_pc = (margin / subtotal * 100) if subtotal else 0

    header.totals = {
        'subtotal': subtotal, 'tax': tax,
        'total': total, 'cost': cost,
        'margin': margin, 'margin_pc': margin_pc,
        'received': received, 'balance': total - received,
    }
    header.save(update_fields=['totals', 'total', 'balance'])
3

Serializer Passes Envelope Intact

No extraction. No flattening. No to_representation surgery. The envelope goes out the way it was computed.

class OrderSerializer(ModelSerializer):
    class Meta:
        model = Order
        fields = [
            'id', 'ida', 'status',
            'cost', 'sell', 'totals',   # envelopes intact
            'total', 'balance',          # indexes for queries
            'lines', ...
        ]
        read_only_fields = ['totals', 'total', 'balance']
4

React Resolves by Path

Components don't know what they're rendering. They resolve a path from configuration and display the result.

// Column definition — path from configuration
const columns = [
  { header: 'Total',   accessorKey: 'totals.total',     format: 'currency' },
  { header: 'Margin',  accessorKey: 'totals.margin_pc', format: 'percent'  },
  { header: 'Balance', accessorKey: 'totals.balance',   format: 'currency' },
];

// Component renders any model — it reads the schema
<DynamicDetail
  data={record}
  schema={pydanticSchema}
  layout={settingsLayout}
/>

The Four Rules

1

Pydantic is the schema authority

Field behaviors — types, labels, formatting, validation, widgets — are declared in Pydantic schema code. Not in the database. Not in React. Not in Settings. Settings store layouts (which fields to show and where). Pydantic stores behaviors (what the field is and how it acts).

2

JSON envelopes travel intact

Serializers never flatten, extract, or reshape. No data['total_amount'] = instance.sell.get('total'). The envelope goes out the way it was computed. Round-trip safe. PATCH returns what GET served.

3

Paths replace property access

Components resolve values through dot-path notation from configuration. accessorKey: 'totals.balance', not record.balance. The path comes from the schema or layout settings, not the component source code.

4

One engine computes

Every computed value has exactly one producer. React reads, it does not compute authoritative values. Selection-aware subtotals (user-selected line subsets) are legitimate local computation — labeled as partial, never confused with the server total.

Get Started

Read the Architecture

Full technical readme with code examples, decision rationale, and industry research.

View on GitHub

See It Running

WebClerk is the open-source reference implementation. 30+ models, all rendered by DynamicDetail using PJPV.

WebClerk.com

Apply It

PJPV works with any Django + DRF + React stack. It also applies to FastAPI, Flask, or any backend where Pydantic defines the schema.

Implementation Guide