<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <id>mcgown.dev</id>
    <title>Stewart McGown</title>
    <updated>2026-10-17T02:24:55.997Z</updated>
    <generator>https://github.com/jpmonette/feed</generator>
    <link rel="alternate" href="https://mcgown.dev"/>
    <subtitle>Stewart McGown</subtitle>
    <logo>https://cdn.ecoeats.uk/apple-touch-icon.png</logo>
    <rights>ecoeats 2026</rights>
    <entry>
        <title type="html"><![CDATA[Stale-while-revalidate]]></title>
        <id>https://mcgown.dev/4cpqd-1472</id>
        <link href="https://mcgown.dev/4cpqd-1472"/>
        <updated>2026-09-14T09:46:35.526Z</updated>
        <summary type="html"><![CDATA[Caching a restaurant is easy.

Caching its menu, its availability, a search result containing it, an]]></summary>
        <content type="html"><![CDATA[Caching a restaurant is easy.

Caching its menu, its availability, a search result containing it, and a slightly different view of the same thing is also easy.

Changing the restaurant afterwards is where the conversation becomes less cheerful.

The ecoeats cache layer used stale-while-revalidate and an entity registry: cached results could declare which entities they depended on, so a change could invalidate more than one hand-picked key.

Nothing mystical. Just keeping a list of the things we'd otherwise forget.



The views and identifiers are illustrative. This is a conceptual dependency graph, not a production key inventory.
A cache key describes a question
restaurant:42 sounds like a perfectly good key until one caller means “load this restaurant” and another means “load it only if it is open”.

Those are different questions. Sharing a key doesn't make their answers equivalent.

The original cache service documented this problem explicitly. Arguments and query semantics belong in the key. So do relevant boundaries such as locale, permission scope, or tenant context where those affect the answer.

A well-named entity isn't enough. The key needs to identify the result you actually computed.
Fresh, stale, and too old are different states
Stale-while-revalidate separates two concerns: whether cached data can still be served, and whether a refresh should begin.

Fresh data can be returned directly. Stale-but-acceptable data can be returned while a refresh runs. Data beyond the allowed maximum age should no longer be treated as an acceptable answer without a successful refresh, unless you've explicitly designed a stale-on-error policy.

Here is a simplified policy sketch, not a complete implementation:

no cached value       → fetch before answering
age below fresh limit → return cached value
age below max limit   → return cached value; start deduplicated refresh
age beyond max limit  → wait for fresh data or apply explicit failure policy

Those limits should come from the use case. A description can tolerate different staleness from a price or an authorization decision. “We cache everything for a minute” is a configuration, not a reason.
Register the dependency when you create the result
The entity registry gives us a reverse lookup:

entity:restaurant:example-42
  → menu-view:example-42:en
  → restaurant-card:example-42
  → availability-view:example-42:delivery

When a cached result is written, register its dependencies. When the entity changes, look up those result keys and invalidate them.

The historical implementation used Redis-backed registry membership and deletion. Its single-key API distinguished soft and hard invalidation, while entity invalidation was implemented as hard deletion. Those are separate capabilities; a general diagram shouldn't imply both modes existed for every path.

Soft invalidation marks a value stale so the next reader can receive it while a refresh starts. Hard invalidation removes the value so the next read must fetch. Neither is universally correct.
The missing dependency is often a query
An entity registry handles “this result contains restaurant 42” quite nicely.

What about a search result that should now contain restaurant 42 but didn't contain it before?

There was no entity membership to register. Likewise, changing a sorting field may affect a result page the entity never appeared on.

For those cases, you need broader dependencies such as a collection or query-scope tag, versioned query keys, or bounded expiry. The registry doesn't discover relationships by magic. Somebody still has to model the invalidation domain.

That is the part worth discussing before the Redis code.
Deleting a key doesn't stop an old writer
Imagine a refresh starts and reads an old value. Then a write commits and invalidates the key. Finally, the refresh completes and writes its old answer back.

Congratulations: the cache is populated again, and still wrong.

A more robust design can use entity generations or source revisions, checking that the generation hasn't changed before storing the refresh result. Compare-and-set logic, transactional outbox processing, or other mechanisms may be appropriate depending on the consistency you need.

A lock used to deduplicate refreshes is useful, but isn't automatically a solution to every race. The database write and the cache have different lifecycles.

I'm describing improvements and design choices here, not claiming the original wrapper solved them all.
Bookkeeping also has a cost
Registry entries need cleanup. A popular entity may depend on many cached results, making invalidation an expensive fan-out operation. An atomic script can still block Redis for too long if you give it an enormous amount of work.

Bound membership, observe invalidation size, and decide how expired results leave the registry. For large dependency sets, generation-based keys can trade immediate deletion for later cleanup.

Test concurrent refresh and invalidation, a failed refresh, a missing registry entry, and a collection result gaining a new member. These are more revealing than checking that get returns whatever set just stored.
Make staleness a decision
The point of a registry isn't to declare cache invalidation solved. It's to make the dependencies visible enough to reason about.

Name the question. Register the things its answer depends on. Decide which stale answers are acceptable. Preserve the right to say that some answers must be fresh.

The difficult part isn't remembering how to delete a Redis key. It's remembering which question that key was answering.]]></content>
        <author>
            <name>Stewart McGown</name>
            <email>team@ecoeats.uk</email>
            <uri>https://mcgown.dev/author/8702140</uri>
        </author>
        <published>2026-09-14T09:46:35.526Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[Programmable Order Receipts with the Sunmi V2]]></title>
        <id>https://mcgown.dev/4cpqd-1452</id>
        <link href="https://mcgown.dev/4cpqd-1452"/>
        <updated>2026-09-14T09:46:35.480Z</updated>
        <summary type="html"><![CDATA[Ever wondered how you can manage a restaurant's order flow using an Android tablet and React Native?]]></summary>
        <content type="html"><![CDATA[Ever wondered how you can manage a restaurant's order flow using an Android tablet and React Native?



I've shortened the code for readability and used fictional order data in the illustrations. The snippets use the original stack; adaptations and newer suggestions are labelled. I haven't run these examples on a Sunmi for this article.
First, make Android print something
I used Janus Lo's react-native-sunmi-inner-printer to talk to the built-in printer. Janus wrote that bridge; I built the receipt logic and added my own application-control modules alongside it.

The package's JavaScript entry point is wonderfully short:

import { NativeModules } from 'react-native';

const SunmiInnerPrinter = NativeModules.SunmiInnerPrinter;

On the Java side, the library binds to Sunmi's printer service. Android supplies an IBinder, which the generated AIDL interface turns into something the module can call:

// Excerpt from the third-party printer bridge.
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
    woyouService = IWoyouService.Stub.asInterface(service);
}

That service connection takes time. A native module can exist before its printer service connects, so a real app needs a readiness check and a useful failure state.

Once the service is available, printing a heading looks like this:

import SunmiInnerPrinter from 'react-native-sunmi-inner-printer';

await SunmiInnerPrinter.setAlignment(1); // 0 left, 1 centre, 2 right
await SunmiInnerPrinter.setFontSize(50);
await SunmiInnerPrinter.printOriginalText('ecoeats\n');
await SunmiInnerPrinter.setFontSize(80);
await SunmiInnerPrinter.printOriginalText('#042\n');

The newline matters. The printer can hold a partial line until it receives enough text or a line break. Forget it and you get to stare at the machine wondering which layer has decided to ignore you.

Formatting persists between calls too. Set centre alignment and subsequent text stays centred until you change it. I explicitly switched back to left alignment before the customer details and food.

Already quite a different experience from adding another <Text> element.
Three bytes for bold
For bold text, I sent raw command bytes around the text. The command was ESC E n: 0x1b, 0x45, then 1 to enable emphasis or 0 to disable it.

The bridge accepts raw data as Base64. My original helper converted the bytes to a binary string and encoded it with the project's btoa utility.

Here's a cleaned-up version, with finally added to attempt to restore normal formatting after a text error:

import { btoa } from '../utils/base64';

function encodeBytes(bytes: number[]) {
  return btoa(String.fromCharCode(...bytes));
}

async function setBold(enabled: boolean) {
  await SunmiInnerPrinter.sendRAWData(
    encodeBytes([0x1b, 0x45, enabled ? 1 : 0]),
  );
}

async function printBold(text: string) {
  await setBold(true);
  try {
    await SunmiInnerPrinter.printOriginalText(text);
  } finally {
    await setBold(false);
  }
}

Keep that helper for byte payloads. Unicode text needs the printer's text API or an explicitly chosen encoding; feeding arbitrary strings through a binary-string Base64 helper invites a separate afternoon of character-set misery.



The library decodes the payload in Java:

// Excerpt from the third-party printer bridge.
final byte[] data = Base64.decode(base64EncriptedData, Base64.DEFAULT);

// The actual implementation dispatches work through its thread pool
// and maps service callbacks to the React Native Promise.
printerService.sendRAWData(data, callback);

That is the path from a TypeScript array to the printer's formatting state. I find it disproportionately pleasing that [0x1b, 0x45, 0x01] can make an allergy heading stand out on a piece of paper.

The shared formatting state also explains why print jobs need serialisation. Interleave two receipts and one job can change the other's font size or alignment halfway through. I used a local lock around order printing; a stronger implementation would funnel every print entry point, including shipment tickets, through one queue.

Keep each whole receipt together. A queue around individual lines would still allow an impressively confusing collaboration between two orders.
Designing for a very narrow piece of paper
The receipt had a clear reading order: brand, restaurant, enormous order number, pickup time, customer details, warnings, food, modifiers, and total.

I varied the font size aggressively. The original renderer used 80 for the order number, 40 for category headings and items, 30 for modifier-group labels, and 34 for the actual choices. Smaller metadata sat below the header.

Those values belong to the printer API's font sizing. Don't assume they produce the same physical result as a React Native screen font with the same number.

The header code followed the hierarchy directly:

// Condensed from the receipt renderer.
await SunmiInnerPrinter.setAlignment(1);

await SunmiInnerPrinter.setFontSize(50);
await printBold('ecoeats\n');

await SunmiInnerPrinter.setFontSize(30);
await SunmiInnerPrinter.printOriginalText(`${restaurant.name}\n`);

await SunmiInnerPrinter.setFontSize(80);
await SunmiInnerPrinter.printOriginalText(`#${order.number}\n`);

await SunmiInnerPrinter.setFontSize(30);
await printBold(`Pickup Time: ${pickupLabel}\n`);

await SunmiInnerPrinter.setAlignment(0);

Here pickupLabel represents a formatted time. For a new implementation I'd pass the restaurant's time zone explicitly to the formatter rather than rely on the device's locale and clock settings.

The order number earns the largest type because people need to match food, tickets, and collections quickly. Long restaurant names can wrap. A tiny order number would make every handoff slightly more annoying.

I gave allergy notes their own section and kept packaging instructions close to the relevant food. Reusable orders could also trigger a prominent warning near the top of the ticket. Paper doesn't have the screen's coloured badges, so font size, emphasis, spacing, and placement had to do the work.

The printer bridge documents a maximum bitmap width of 384 pixels for the interface I used. I sent square QR images at 380 × 380. That narrow width also makes typography a balancing act: increase the font size and a previously neat item name suddenly occupies two lines.

Avoid designing a receipt around short demo strings. Try a long menu item, several modifier groups, a substantial allergy note, and a multi-digit quantity. Keep critical instructions intact when they wrap.

For aligned totals, the library also offers a column API:

// Optional extension using the bridge API; my order renderer used text lines.
await SunmiInnerPrinter.setFontSize(24);
await SunmiInnerPrinter.printColumnsText(
  ['Subtotal', '£18.50'],
  [22, 10],
  [0, 2], // left label, right amount
);

The widths are character-based allocations, not CSS pixels. All three arrays must have matching lengths. Font size and glyph width affect the result, so print a test on the target device before treating these example widths as a layout guarantee.

My old file still has unused column-width variables near the top. The actual receipt path printed lines. A familiar archaeological layer: the approach you considered is still sitting beside the one you shipped.
Give the modifiers somewhere sensible to live
A basket arrives as data with relationships. The kitchen needs a readable hierarchy:

MAINS
  2x Vegetable curry
     Rice
       Pilau
     Extras
       2x Chutney

I wrote computeCategoriesWithModifierGroups to join basket items back to the menu, group them into categories, collect their modifiers, and sort the groups and choices by the menu's display order.

Then both the order screen and the receipt consumed that organised view. I could change how I displayed a modifier without inventing another interpretation of the basket.

The shape looked roughly like this:

type KitchenCategory = {
  title: string;
  items: {
    id: string;
    name: string;
    quantity: number;
    modifierItems: {
      id: string;
      name: string;
      items: { id: string; name: string; quantity: number }[];
    }[];
  }[];

I used repeated lookups in the original transformation. I'd pre-index larger menus now:

// A small improvement to the lookup step.
const itemsById = new Map(menu.items.map(item => [item.id, item]));
const groupsById = new Map(
  menu.modifierGroups.map(group => [group.id, group]),
);

const menuItem = itemsById.get(basketItem.menuItemId);
if (!menuItem) {
  throw new Error('Refresh the menu before rendering this order');
}

The original screen could refetch the menu when the transformation reported a missing item. For durable order records, I'd also keep the purchased names and choices with the order itself. A menu edit shouldn't rewrite yesterday's dinner.

Once the grouping was ready, the printer renderer could concentrate on indentation and type:

// Condensed from the item/modifier renderer.
for (const category of categories) {
  await SunmiInnerPrinter.setFontSize(40);
  await printBold(`${category.title}\n`);

  for (const item of category.items) {
    await SunmiInnerPrinter.setFontSize(40);
    await SunmiInnerPrinter.printOriginalText(
      `${item.quantity}x ${item.name}\n`,
    );

    for (const group of item.modifierItems) {
      await SunmiInnerPrinter.setFontSize(30);
      await SunmiInnerPrinter.printOriginalText(`   ${group.name}\n`);
      await SunmiInnerPrinter.setFontSize(34);

      for (const choice of group.items) {
        const quantity = choice.quantity > 1 ? `${choice.quantity}x ` : '';
        await SunmiInnerPrinter.printOriginalText(
          `    ${quantity}${choice.name}\n`,
        );
      }
    }
  }
}

I printed a quantity for a modifier only when it exceeded one. Small choice, useful result: the unusual quantity stands out while ordinary selections stay compact.

For reusable orders, I inserted the named container instruction immediately after the item. That gave the person packing the order the information beside the thing they were packing.

Then I printed the subtotal using integer pennies and fed a few blank lines so the last text cleared the tear edge. Physical margins. Finally, a CSS problem I could solve with \n.
Sending a QR code from React to paper
For the shipment-related order view, I rendered a QR code with react-native-qrcode-svg, kept a ref, and exported the image when the user requested a print.

The route looked like this, with a fictional identifier:

// Adapted to show the ref/export path without private order URLs.
type QRHandle = {
  toDataURL: (callback: (base64: string) => void) => void;
};

const qrRef = useRef<QRHandle | null>(null);

<QRCode
  value="DEMO-ORDER-042"
  size={256}
  getRef={ref => { qrRef.current = ref; }}
/>

async function printQr() {
  const handle = qrRef.current;
  if (!handle) throw new Error('QR code has not rendered yet');

  const base64 = await new Promise<string>(resolve => {
    handle.toDataURL(resolve);
  });

  await SunmiInnerPrinter.setAlignment(1);
  await SunmiInnerPrinter.printBitmap(base64, 380, 380);
}

The bridge decodes the Base64 image, constructs an Android Bitmap, and passes it to the printer service. React draws the graphic; Android gives it a form the printer understands.

For a production helper, add a timeout around export and make sure an unmounted component can't leave a print request waiting forever.

I'd also generate at the intended raster size where possible, use a white background, and preserve the QR quiet zone. Resizing a code can soften its edges or distort its modules. Test a physical print with the scanners you expect people to use.

The library exposes a direct printQRCode method too. That is worth trying when you only need a code and want the printer to render its modules. Exporting from React was convenient for the visual I already had.

There is a cut-paper command helper in the old file as well, but the order-print path doesn't call it. Check the device's actual cutter capability before wiring one in. A byte sequence cannot persuade a tear bar to grow a motor.
Fitting the same order onto the screen
On the device, I had colour, touch, and scrolling to play with. I also had a small display and potentially a very long order.

I kept the order contents in a flexible ScrollView and placed the action footer beside it in the component tree. The food could scroll while Accept, Reject, or Ready remained reachable.



The structure was straightforward:

// Simplified from OrderView.
<View style={{ flex: 1 }}>
  <ScrollView style={{ flex: 1 }}>
    <OrderSummary />
    <AllergyNotes />
    <KitchenItems categories={categories} />
  </ScrollView>

  <View style={{ padding: 16 }}>
    <OrderActions order={order} />
  </View>
</View>

A footer inside the scrolling content would disappear beneath the modifiers. An absolutely positioned footer could cover the final item unless I carefully reserved its height. Keeping it as a sibling let the layout engine allocate the space.

Within an item, I gave the quantity a compact column and let the name take the remaining width:

// Adapted from the order item layout.
<View style={{ flexDirection: 'row', alignItems: 'flex-start' }}>
  <Text style={{ color: accent, fontSize: 18 }}>
    {item.quantity}x
  </Text>
  <View style={{ flex: 1, marginLeft: 8 }}>
    <Text style={{ fontSize: 18 }}>{item.name}</Text>
    <ModifierGroups groups={item.modifierItems} />
  </View>
</View>

That flex: 1 gives long names a width to wrap within. The modifiers sit underneath the name, so the quantity remains attached to the main item instead of competing with every extra.

For reusable packaging, I added an illustrated container panel. People could tap it for more detail. The printed receipt repeated the container name in bold.

The home screen used a SectionList for current work and orders to prepare later. Individual rows used a fixed height in that version, with order information on the left and a new-order badge or time on the right. Compact rows let the kitchen scan several orders without immediately scrolling.

There are trade-offs in that fixed height. Long names, translations, and larger accessibility text need deliberate testing. I'd let the row grow where necessary rather than clip something operationally important.

I also delayed the order query until the navigation interaction had a chance to finish. The historical screen used InteractionManager.runAfterInteractions with a timer fallback. That kept a pile of work from starting at the same moment as the screen transition.

For the old React Native stack, this is a tidier version of the cleanup:

useEffect(() => {
  const interaction = InteractionManager.runAfterInteractions(
    () => setReady(true),
  );
  const fallback = setTimeout(() => setReady(true), 500);

  return () => {
    interaction.cancel();
    clearTimeout(fallback);
  };
}, []);

The screen also measured viewport height, scroll offset, and content height for a scroll affordance. The relationship to use is:

const remainingBelow = contentHeight - (scrollY + viewportHeight);
const showScrollHint = remainingBelow > threshold;

I'd use that explicit calculation when revisiting the old condition. A scroll hint should point to actual hidden content; it shouldn't require an interpretive dance around three height variables.
Writing my own Java modules
The printer package covered the printer. I wrote PhoenixModule and ToucherModule for other device-level experiments and controls, then registered them through an AppPackage.

This was the classic React Native Android bridge. The module returned a JavaScript-visible name from getName(), and @ReactMethod exposed methods to JavaScript.

My package registration looked like this:

@Override
public List<NativeModule> createNativeModules(
        ReactApplicationContext reactContext) {
    List<NativeModule> modules = new ArrayList<>();
    modules.add(new ToucherModule(reactContext));
    modules.add(new PhoenixModule(reactContext));
    return modules;
}

Then I added the package to the application's package list:

List<ReactPackage> packages = new PackageList(this).getPackages();
packages.add(new AppPackage());
return packages;

After compiling those native modules into the APK, JavaScript could reach them through NativeModules. A CodePush update could call existing native methods, but adding a new method still required a native build.

I also added one very useful line to MainActivity:

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    getWindow().addFlags(
        WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
    );
}

The flag keeps the screen awake while that activity's window is visible. The kitchen could leave the order screen in view without poking the device just to keep it lit. Background work still needed its own lifecycle.
Phoenix: arrange the return before making the exit
PhoenixModule exposed a restart method that scheduled the splash activity and then exited the process. The relevant code looked like this:

// Historical implementation, abbreviated.
Intent start = new Intent(getCurrentActivity(), SplashActivity.class);

PendingIntent pending = PendingIntent.getActivity(
    mContext,
    123456,
    start,
    PendingIntent.FLAG_CANCEL_CURRENT
);

AlarmManager alarms =
    (AlarmManager) mContext.getSystemService(Context.ALARM_SERVICE);

alarms.set(AlarmManager.RTC, System.currentTimeMillis() + 1000, pending);
System.exit(0);

The idea made me smile: book your return trip before killing the process.

JavaScript stopped the background service before asking Phoenix to restart:

const { PhoenixModule } = NativeModules;

async function restartTerminalApp() {
  await BackgroundService.stop();
  PhoenixModule.restart();
}

This excerpt needs updating for current Android targets: check pending-intent mutability requirements, activity availability, alarm behaviour, and background activity-launch restrictions. A scheduled alarm doesn't guarantee an exact restart one second later.

The module also contained whole-device reboot and app-data-reset methods. Reboot requires privileged permission or appropriate device provisioning; declaring an ordinary app permission won't grant it. Clearing app data removes local application state, so keep that behind an explicit maintenance action with a recovery plan.

I limited automatic reboot attempts to a quiet-time window. I'd check active orders, pending prints, and device readiness as well if I were rebuilding the policy today.
Seeing what the restaurant saw
Even though eceoeats wasn't operating over an enormous area, we still covered a good chunk of Scotland. When something went wrong with our tech in a Kitchen, we had to be able to provide genuinely useful support to the Restaurant without sending somebody down there. Kitchens are an intense environment, and we didn't want to bog down the staff at our partner locations with debugging tablets.

Sunmi exposed a 'remote control' feature from their Web interface, but it was incredibly unreliable. I ended up having to build our own little WebRTC streaming system that allowed connecting to the tablets from our admin dashboard directly, so our operators could take control of the device, issue commands, clicks, and see exactly what the restaurant was seeing. 

I wrote an Android screen-sharing helper with react-native-webrtc. It requested a display stream, attached it to a peer connection, created an SDP offer, and handed that offer to a callback for signalling.

The core sequence used the library API available at the time:

// Shortened from the historical screen-sharing helper.
const pc = new RTCPeerConnection(configuration);
const stream = await mediaDevices.getDisplayMedia({ video: true });

pc.addStream(stream);
pc.onicecandidate = event => {
  if (event.candidate) onCandidate(event.candidate);
};

const description = await pc.createOffer();
await pc.setLocalDescription(description);
onOffer(description, { pc, stream });

The other end still needed to send back its description and ICE candidates. STUN helps peers discover connectivity; some networks require a TURN relay too - but I just ignored this part and hoped for the best.

For an actual support session, authenticate both ends, ask the operator to start sharing, make the session visible, and stop every capture track when it ends. Restaurant screens can contain customer information. Keep that out of recordings and demonstrations unless you have a clear reason and permission.

That helper explains why I enjoyed programming these devices. I could move from React layout to a paper bitmap, then into Android lifecycle code and WebRTC, all in the same project.
Testing without feeding the printer all afternoon
The receipt renderer included a console fallback for development. It replaced printer methods with stubs, remembered the current font size, and logged text like [SIZE=40] 2x Vegetable curry.

That let me inspect category ordering, quantity formatting, and the sequence of font changes without burning through another roll - although I did, of course, burn through a lot of thermal paper!

The real hardware still gets the final say on wrapping, image quality, glyph support, and paper feed. I'd keep a small set of deliberately awkward sample orders: long names, deep modifiers, allergy notes, repeated quantities, and reusable packaging.

The bridge exposes PrinterStatus events for conditions such as paper out and cover open. A small listener can make those failures understandable:

// Suggested UI integration using the printer library's event API.
useEffect(() => {
  const listener = DeviceEventEmitter.addListener(
    'PrinterStatus',
    action => {
      if (action === SunmiInnerPrinter.Constants.OUT_OF_PAPER_ACTION) {
        setPrinterMessage('Load a new paper roll');
      } else if (
        action === SunmiInnerPrinter.Constants.COVER_OPEN_ACTION
      ) {
        setPrinterMessage('Close the printer cover');
      } else if (
        action === SunmiInnerPrinter.Constants.NORMAL_ACTION
      ) {
        setPrinterMessage(null);
      }
    },
  );

  return () => listener.remove();
}, []);
It really was very funny of me to be writing all of these as effects. I was still going through a phase where I stick quite rigidly to all logic being in React lifecycle, when in reality if I'd picked up something like RxJS, a service arch, or really any other event coordination mechanism for business logic I would have had fewer bugs to deal with!

Inspect callback behaviour in the version you ship. Some of the older buffer methods return void, and putting await in front of them doesn't create a completion signal. Surface uncertain print outcomes instead of blindly firing off another copy.

I spent a lot of time on details like these: keeping the action button reachable, putting the modifier beneath the right item, getting a QR image through the bridge, and recovering the native application when a JavaScript reload wouldn't do.
Vintage Video of me testing out an early version of the Restaurant app at home
 
https://t4610797.p.clickup-attachments.com/t4610797/bf27e168-bcf8-4ae3-acb4-3ae77f4ff682/VID_20200224_224725.mp4?view=open
 
  
That order ping sound drove me (and the kitchen staff too) absolutely nuts!

Code references:]]></content>
        <author>
            <name>Stewart McGown</name>
            <email>team@ecoeats.uk</email>
            <uri>https://mcgown.dev/author/8702140</uri>
        </author>
        <published>2026-09-14T09:46:35.480Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[Two routing providers and a straight line]]></title>
        <id>https://mcgown.dev/4cpqd-1412</id>
        <link href="https://mcgown.dev/4cpqd-1412"/>
        <updated>2026-09-14T09:46:35.388Z</updated>
        <summary type="html"><![CDATA[A routing service has one obvious job: tell you how to get from here to there.

A delivery applicati]]></summary>
        <content type="html"><![CDATA[A routing service has one obvious job: tell you how to get from here to there.

A delivery application gives it a less comfortable second job: keep answering when the service you normally ask has stopped answering.

The ecoeats router had a short fallback chain. Try OSRM. Try another routing provider. If both fail, calculate geographical distance and derive a rough duration.

It is pleasingly small code. It also hides an important change in the meaning of the answer.



Illustrative fallback model. No production endpoints, provider credentials, or operational thresholds are shown.
A successful return value can still be a worse answer
A road-aware estimate can account for the network being travelled. A straight-line distance cannot see a river, a one-way system, or the only bridge being in the opposite direction.

That doesn't make the approximation useless. It makes it an approximation.

The original abstraction returned distance and duration through a common interface. That kept callers simple, but a caller receiving only two numbers couldn't necessarily tell whether the result came from a route or a last-resort calculation.

This is where abstraction becomes a little too good at its job. It hides the implementation and the loss of information at the same time.
Preserve the distinction in the result
For a new version, I'd return provenance as part of the contract:

type Estimate = {
  metres: number;
  seconds: number;
  source: 'primary-route' | 'alternate-route' | 'geometric';
  quality: 'road-aware' | 'approximate';
  calculatedAt: string;
};

This is a proposed design, not a claim about the historical API.

I prefer a meaningful quality category over a made-up percentage called confidence. A result labeled “83% confident” isn't more useful unless somebody can explain what that number predicts and how it was calibrated.

Now each caller can make a deliberate decision. A rough preview might accept an approximation. A hard delivery-area boundary or a customer promise may need a different fallback, a wider range, or no answer at all.
Give the fallback a budget
A fallback that starts after the request has already timed out isn't much of a fallback.

Suppose a fictional application has a 900 ms total budget. Spending all of it waiting for the first provider leaves nothing for the second. The deadline belongs to the overall operation, not independently to each attempt.

Propagate cancellation to provider requests where supported. Reserve enough budget for useful alternatives. If a provider is repeatedly failing, a circuit breaker can avoid making every customer wait for the same predictable failure.

Those mechanisms add complexity. They also address a different problem from simply catching an exception and moving on.
Not every error means try the next one
A timeout, a rate limit, invalid coordinates, and an authentication configuration error shouldn't all vanish into the same empty catch block.

Validate inputs before calling providers. Classify failures. A retry or alternate provider may help with temporary unavailability, but it won't make an impossible latitude sensible.

Transport mode also matters. A cycling request should not silently become a driving estimate merely because the fallback supports cars more conveniently. Either preserve the requested semantics or explicitly report that you cannot.

Similarly, the coordinate convention must be clear at every boundary. Swapping latitude and longitude can produce a confident answer about somewhere entirely unrelated to dinner.
Measure degradation, not just uptime
If the fallback always returns a number, your availability chart can look excellent while the quality of your answers gets steadily worse.

Count results by source. Track provider latency and classified failures. Watch the proportion of approximate results, and compare estimates with observed outcomes where you have suitable, privacy-conscious data.

Use synthetic coordinates in logs and examples whenever real locations aren't needed. Don't make route debugging a reason to collect an indefinite history of people's movements.

A useful test matrix includes provider timeout, malformed response, invalid input, unavailable transport mode, and disagreement between providers. Also test a route around an obstacle where a straight line is obviously misleading.
The fallback is a product decision
The engineering temptation is to make the function return something, anything, so the rest of the app can continue.

Sometimes that's right. Sometimes a clear “estimate unavailable” is better than a plausible number being used as a promise.

The right boundary is not between throwing and returning. It's between a result the caller understands and one that quietly means something else.

A straight line can keep the calculation alive. It cannot build a bridge.]]></content>
        <author>
            <name>Stewart McGown</name>
            <email>team@ecoeats.uk</email>
            <uri>https://mcgown.dev/author/8702140</uri>
        </author>
        <published>2026-09-14T09:46:35.388Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[I built a fake delivery company to test the real one]]></title>
        <id>https://mcgown.dev/4cpqd-1392</id>
        <link href="https://mcgown.dev/4cpqd-1392"/>
        <updated>2026-09-14T09:44:51.314Z</updated>
        <summary type="html"><![CDATA[I've always been a proponent for testing complex systems . I built ecoeats before AI really took off]]></summary>
        <content type="html"><![CDATA[I've always been a proponent for testing complex systems . I built ecoeats before AI really took off. Where nowadays an e2e test is simpler to write and maintain, back in the day it was simply too much work to build these and keep them in sync with the reality. It was easier to write simulators, adapters for each part of the order system that could execute events and allow the system to respond in real time to whatever you threw at it.

Simulations formed the backbone of my testing strategy. I could see, end to end in the system, what happened from basket construction to order placement to delivery, as well as inspect the system live on all of the apps that interfaced with the ecoeats system.

It might be worth thinking of this as 'chaos' testing - my simulated customers, riders and restaurants were unpredictable. They would cancel orders, amend them, fail to make deliveries correctly or on time, restaurants would fail to prepare the food anywhere close to the scheduled pickup time - and so on!



A fixture doesn't get hungry
A fixture can tell you whether an order with a particular status renders correctly. It can't, by itself, tell you what happens when a restaurant takes longer to prepare food while a rider is approaching and another order becomes available.

Those are interactions over time. The data has a history, and that history affects which actions should be possible next.

The simulator code covered things such as placing orders, accepting or rejecting work, moving towards destinations, and exercising collection and delivery actions. It also reached into reusable-container workflows. That breadth matters because a delivery business isn't one tidy state machine. It's several state machines trying to agree about lunch.
Make the actors use the application
There are two tempting ways to simulate an order being accepted.

The first is to update its status directly in the database. Very fast. Very easy. Unfortunately, you've just stepped around the code you were hoping to exercise.

The second is to ask the application to perform the acceptance action. That gives its normal rules a chance to reject the request, update related records, or produce whatever subsequent work should follow.

The rider simulation used application-level actions for important transitions. That's the principle worth keeping. It doesn't mean the entire simulator was a pure black-box test: it also used services and database access, and calling a resolver in-process doesn't test HTTP, authentication middleware, or mobile rendering.

I'd call it a behavioural integration simulator, not proof that the whole product works end to end.
A tiny version of the idea
Here is deliberately incomplete TypeScript-style pseudocode for one actor. The application owns the legal transitions; the actor chooses whether to attempt one:

async function stepRider(world, rider, random) {
  const view = await world.readRiderView(rider.id);

  if (view.offer) {
    const action = random() < 0.8 ? 'accept' : 'reject';
    return world.act(rider.id, action, view.offer.id);
  }

  if (view.destination) {
    await world.moveTowards(rider.id, view.destination);
  }

  if (view.availableAction && world.hasArrived(rider.id)) {
    await world.act(rider.id, view.availableAction);
  }
}

The probability is invented for this example. In a useful simulator, behaviour belongs in explicit scenario settings, not unexplained magic numbers scattered through the code.

Start with one merchant, one rider, and one order. Make the happy path legible before adding twenty ways for it to become unhappy.
Randomness finds a problem. A seed lets you keep it
A busy simulation can make a dashboard look wonderfully alive while producing almost no actionable evidence. If a failure disappears on the next run, you now have a ghost story rather than a regression test.

My next iteration would make the clock injectable and the random source seedable. Record the seed, scenario settings, and actions attempted. Keep a compact trace of accepted transitions and rejected ones. That way, an interesting failure can become a deterministic test.

The historical implementation used ordinary randomness and scheduled work in places. Reproducibility is an improvement I'd make, not a property I'm claiming it already had everywhere.
Simulated doesn't automatically mean safe
A fake customer can still trigger a real email if you connect the wrong adapter.

Run a system like this against isolated data, sandbox payment services, and disabled or intercepted external messaging. Refuse production credentials at startup. Put a visible simulation marker on generated records and make cleanup bounded and repeatable.

An environment-name check is helpful, but it isn't the entire safety boundary. The safest simulator cannot reach real money or real people in the first place.
What to measure
I'd start with invariants: completed work should have the required preceding transitions; rejected offers shouldn't leave phantom assignments; a cancelled order shouldn't quietly continue through fulfilment.

Then look at liveness. Does work eventually move forward when capacity exists? Can a scenario get stuck without producing a visible error? Does a burst of activity expose something that the single-order path doesn't?

Don't claim performance improvements from a lively map. A realistic simulation and a calibrated load test are different instruments.
Build the smallest world that can disagree with you
There is a trap here: spending so long simulating the business that you accidentally build a second business.

The useful version is smaller. It exercises the troublesome transitions, preserves the interesting failures, and gives you enough control to repeat them.

A fixture says, “Imagine an order in this state.” A behavioural simulator asks, “How did it get there, and can it get out again?”

That's a much better question.]]></content>
        <author>
            <name>Stewart McGown</name>
            <email>team@ecoeats.uk</email>
            <uri>https://mcgown.dev/author/8702140</uri>
        </author>
        <published>2026-09-14T09:44:51.314Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[Your dinner is arriving in five minutes. Again.]]></title>
        <id>https://mcgown.dev/4cpqd-1372</id>
        <link href="https://mcgown.dev/4cpqd-1372"/>
        <updated>2026-09-14T09:44:51.295Z</updated>
        <summary type="html"><![CDATA[Working on ecoeats meant dealing with the uncomfortable fact that a delivery estimate isn't one calc]]></summary>
        <content type="html"><![CDATA[Working on ecoeats meant dealing with the uncomfortable fact that a delivery estimate isn't one calculation. It's a chain of predictions about people, kitchens, vehicles, and front doors. The route is only part of it.



Conceptual illustration. The scenarios and model below are simplified, not historical performance measurements.
Before the rider moves, the map isn't the main problem
A distance service can estimate travel time between two points. It cannot tell you whether the food is ready, whether somebody has accepted the collection, or whether the customer will answer the door.

Our estimate-review code treated orders differently according to their current stage. Work still being prepared needed different reasoning from work travelling to the customer. The system also considered situations where the expected collection time had passed or a rider wasn't yet in place.

That distinction is the core of a useful model: ask what remains uncertain at this stage, not just how far away the address is.
Two clocks have to meet
For a simplified single-stop delivery, collection can't happen until both the food and the rider are ready.

possible collection time = max(food ready time, rider arrival time)
possible delivery time  = collection + handoff + onward travel

This is a conceptual model, not the original algorithm. It ignores batching and several other complications. But it makes one important dependency visible.

If the rider arrives before the food, shaving another minute off the route to the restaurant changes nothing. If the food is ready but no rider is available, a better kitchen estimate doesn't solve that either.

Optimising the wrong component can produce a very precise answer to the wrong question.
A missing observation is not fresh evidence
Suppose collection was expected at 18:30. At 18:31, we still haven't observed it.

Moving the estimate forward is understandable. But adding a small delay every time a background job runs isn't the same as learning when the food will actually be ready. It is acknowledging that the previous estimate failed.

I'd keep those ideas separate. A revised kitchen readiness signal is new evidence. A rider's recent location is new evidence. “The old estimate is now in the past” tells us less, even though we still need to respond to it.

The interface should not communicate all three with the same confidence.
Give the estimate a reason
An estimate object can carry more than a timestamp. Here's a proposed shape for a new implementation:

type DeliveryEstimate = {
  earliest: string;
  latest: string;
  updatedAt: string;
  reason: 'preparing' | 'awaiting-rider' | 'travelling' | 'handoff';
  evidence: 'recent-observation' | 'fallback';
};

Those fields don't magically make the model accurate. They make its assumptions inspectable.

A range shouldn't be arbitrary decoration either. If you display an interval, measure how often deliveries fall inside it. Otherwise you've simply replaced one unjustified number with two.

For users, a short explanation such as “The restaurant is still preparing your order” can be more useful than a newly confident countdown. Don't expose a stream of internal state changes; explain the change that matters to the person waiting.
Don't turn GPS into a lie detector
Once a rider is travelling, location and routing become much more useful. They still aren't ground truth.

Location can be stale, imprecise, or unavailable. Arrival near a building isn't proof of handoff. A difficult entrance can consume time that the road network doesn't represent.

Keep the age of a location observation alongside its coordinates. Distinguish “arrived nearby” from “delivered”. Treat unexpected timing as a signal to investigate, not automatic proof that a person did something wrong.

The application should help operations understand uncertainty rather than burying it under a decimal place.
Judge the estimate when it was shown
A final estimate updated seconds before delivery can look excellent in a report. That doesn't help the customer who saw something wildly different when ordering.

I'd evaluate predictions by when they were made: at checkout, after acceptance, after collection, and near arrival. Track absolute error, how often promised windows are missed, and how frequently the estimate changes.

Also separate stages. An excellent travel estimate can coexist with a terrible overall promise if preparation or assignment is the real source of delay.

A sensible test suite includes delayed preparation, no available rider, stale location, cancellation, and a customer handoff that takes longer than expected. Use a controllable clock so these tests don't require somebody to wait around hungry.
Less certainty, more honesty
There will always be situations where the system doesn't know enough. The engineering task isn't to eliminate that fact. It's to stop disguising it.

A good estimate is updated when the world changes, carries an understandable reason, and becomes more precise as the remaining work becomes more observable.

If the food isn't ready, the honest answer isn't necessarily another five minutes. Sometimes it's admitting that the kitchen knows something your map doesn't.]]></content>
        <author>
            <name>Stewart McGown</name>
            <email>team@ecoeats.uk</email>
            <uri>https://mcgown.dev/author/8702140</uri>
        </author>
        <published>2026-09-14T09:44:51.295Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[Modelling Reusable Containers at ecoeats]]></title>
        <id>https://mcgown.dev/4cpqd-1352</id>
        <link href="https://mcgown.dev/4cpqd-1352"/>
        <updated>2026-09-14T09:44:51.294Z</updated>
        <summary type="html"><![CDATA[A takeaway box is a wonderfully simple object until you want it back.

Then it needs an identity. An]]></summary>
        <content type="html"><![CDATA[A takeaway box is a wonderfully simple object until you want it back.

Then it needs an identity. And a history. And a way to survive somebody replacing its label without becoming, administratively speaking, a completely different box.

At ecoeats, reusable containers weren't just a checkbox on an order. The code modeled individual physical containers and the observations linking them to returns, washing, pickups, and shipments.

The satisfying bit wasn't putting a QR code on plastic. It was working out what that scan actually meant.



The box number and lifecycle diagram are illustrative. They are not records from the service.
Identity belongs to the object, not the sticker
A label is how the system recognises a container. It isn't necessarily the container itself.

Labels get damaged. Codes can be replaced. If replacing a sticker creates a new asset record, you lose the continuity you were trying to build.

The container model separated the physical object's identity from alternative codes associated with it. That makes a useful general distinction for any physical asset system: identity is durable; the identifier you can currently read may not be.

It applies just as well to rented equipment, library items, or reusable shipping crates. A new label shouldn't erase an old history.
A scan is an observation, not omniscience
Imagine a box is scanned at a return point at 09:00. At 09:01, someone moves it.

Your database still knows where it was observed at 09:00. It does not suddenly know where the box is now.

That sounds obvious until you build a field called currentLocation and let everyone forget what it really represents.

The useful distinction is between physical identity, observed events, and derived state. The original model had separate container and scan records, with contextual relationships for different parts of the return process. I'd preserve that separation rather than squeezing everything into one status column.
A small model with honest names
Here's an illustrative design, not the original schema:

type Container = {
  id: string;
  typeId: string;
};

type ContainerCode = {
  code: string;
  containerId: string;
  retiredAt?: string;
};

type Observation = {
  id: string;
  containerId: string;
  occurredAt: string;
  recordedAt: string;
  kind: 'returned' | 'received-for-washing' | 'dispatched';
  workflowId: string;
};

occurredAt and recordedAt answer different questions. A scanner may upload late. A server may receive two observations in an order different from the physical events.

That doesn't require a grand event-sourcing architecture. It requires you to avoid pretending that insertion order is automatically reality.
Duplicate scans aren't all the same mistake
Scanning the same box twice during one return should probably not count as returning two boxes.

Scanning that box again after another complete use cycle is entirely legitimate. A permanent unique constraint on the container ID would stop the business working after the first return. Very efficient, in a terrible way.

Deduplication needs a scope: the return session, shipment, workflow, or explicit idempotency key. The original scan model included uniqueness rules tied to particular contexts rather than treating every observation as interchangeable.

For a new design, decide the operation's meaning first. Then make the database enforce it atomically. A “does this exist?” query followed by an insert still races when two devices do it together.
The missing scan is the interesting case
The happy path is almost suspiciously neat: issued, used, returned, washed, issued again.

Real workflows contain gaps. A return point might have an unrecognised code. A shipment might contain fewer containers than expected. A box might be physically present without the expected earlier observation.

I'd model those as explicit reconciliation work. Keep the observation that actually happened, flag the inconsistency, and give an authorised person a way to resolve it with an explanation.

Don't invent the missing steps just to make the diagram look tidy. You will eventually use that tidy diagram to make a financial or operational decision.
Track less about people, more about the workflow
Asset tracking can quietly become people tracking if every scan accumulates identity and precise location indefinitely.

Store the context needed for the operation, restrict access, and choose retention periods deliberately. A demonstration doesn't need real customer identities or locations, and neither does most debugging.

Likewise, scanning a code isn't proof that the scanner is authorised to claim a reward or change custody. Identifier possession and permission are different concerns.
The return journey is the product
Software can't make someone return a box by maintaining a beautiful relational schema. The physical process still has to be convenient.

But the schema can support a convenient process, or fight it. Durable identities, scoped deduplication, and honest observations let you handle the messy bits without losing track of what you know.

The box comes back. Eventually. The database should be ready for both words.

[image:c4116f3c-1eca-432f-ae2a-fe8d7d2d1cbf.png]]]></content>
        <author>
            <name>Stewart McGown</name>
            <email>team@ecoeats.uk</email>
            <uri>https://mcgown.dev/author/8702140</uri>
        </author>
        <published>2026-09-14T09:44:51.294Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[Beyond GraphQL]]></title>
        <id>https://mcgown.dev/4cpqd-1312</id>
        <link href="https://mcgown.dev/4cpqd-1312"/>
        <updated>2026-06-02T08:45:18.051Z</updated>
        <summary type="html"><![CDATA[No full post here yet. Just some musings on how I've been approaching entity normalisation at scale.]]></summary>
        <content type="html"><![CDATA[No full post here yet. Just some musings on how I've been approaching entity normalisation at scale.

GraphQL makes an excellent transport layer and query definition layer
I'm finding that it falls short for entity normalisation and 'simple' client interfaces.

GraphQL:
query ABC {
  rider(id: $id) {
    id
    orders {
      id
      title
      status
    }
   }
}

const result = await graphQL.query(ABC);
result; // some json document

// Just want the orders?
... I assume some graphql specific mechanic to get them

Hypothetical namespace/entity data layer
const rider = await Rider.byId('abc', { orders: true });
const recentOrder = await Orders.byRelation('rider', 'abc');
const inflatedRestObject = await Rider. Rider.byId('abc', { orders: true }, { inflate: true }); // { id, orders: [{ id, status: 'x' ... }
Definitely just re-inventing a database here

I actually quite dislike the field-level control GraphQL gives you. Maybe I've gone REST native. It seems to be the only optional fields in an API should be relationships, edges, connections to other entities. If the DB is joining to create fields on a single response object then in my mind it is probably just as efficient to let it do it rather than maintaining multiple shapes for the same entity.

In a relationship scenario, then optional params make sense.]]></content>
        <author>
            <name>Stewart McGown</name>
            <email>team@ecoeats.uk</email>
            <uri>https://mcgown.dev/author/8702140</uri>
        </author>
        <published>2026-06-02T08:45:18.051Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[Intenso]]></title>
        <id>https://mcgown.dev/4cpqd-1147</id>
        <link href="https://mcgown.dev/4cpqd-1147"/>
        <updated>2023-06-21T11:37:21.829Z</updated>
        <summary type="html"><![CDATA[Brew very intense websites from ClickUp Docs]]></summary>
        <content type="html"><![CDATA[Brew very intense websites from ClickUp Docs]]></content>
        <author>
            <name>Stewart McGown</name>
            <email>team@ecoeats.uk</email>
            <uri>https://mcgown.dev/author/8702140</uri>
        </author>
        <published>2023-06-21T11:37:21.829Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[Projects]]></title>
        <id>https://mcgown.dev/4cpqd-1127</id>
        <link href="https://mcgown.dev/4cpqd-1127"/>
        <updated>2023-06-21T11:27:39.801Z</updated>
        <summary type="html"><![CDATA[[Badge text=Startup]

The food delivery startup that gave me so much, but ultimately did not succeed]]></summary>
        <content type="html"><![CDATA[[Badge text=Startup]

The food delivery startup that gave me so much, but ultimately did not succeed. I cut my teeth on proper management of a large technology stack, from Kubernetes to NestJS to React Native and everything in between - as well as leading a team of engineers.



[Video url="https://t2494053.p.clickup-attachments.com/t2494053/7f7e7e9d-5203-4315-b613-c6788e684777/Intenso720.mov"]

☕️ Intenso [Badge text=Startup]

Brew very intense websites from ClickUp Docs.

This entire site is running from an edge-rendered Cloudflare Worker, instantly turning a ClickUp Doc into a static webpage.




Unlimited Drive Storage [Badge text="Open Source"]

My "internet fame" moment came with UDS, an insane project that mapped files from your computer into Google Docs. Back when Google Docs used exactly zero bytes to store, my friend Chloe He and I came up with a mad solution. A slapdash of Python code, a submission to Hacker News and we had a sensation on our hands. Employees at Google even weighed in to tell us of the discussions they were having about it internally!




Grammarly API [Badge text="Open Source"]

Allows you to get access to the results that Grammarly on the web would provide you with.




PhotoMap  [Badge text="Open Source"]
View your Google Photos on a map




MyTutor Calendar  [Badge text="Open Source"]
Automatically sync tutor sessions to your calendar




Young Scientists Journal  [Badge text=Institution]
Science journal for young people




Young Scientists Journal: Issue 20 [Badge text=Institution]
Fresh issue format for a journal




Compass [Badge text=Consulting]
Edinburgh-based counselling.



GoogPress
Build websites using Google Docs



AxisFX
An innovative motion designer.




Greenshoots
A well-established landscaping group.]]></content>
        <author>
            <name>Stewart McGown</name>
            <email>team@ecoeats.uk</email>
            <uri>https://mcgown.dev/author/8702140</uri>
        </author>
        <published>2023-06-21T11:27:39.801Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[Speed Up React Native Development with Utility Stylesheets 🚀]]></title>
        <id>https://mcgown.dev/4cpqd-548</id>
        <link href="https://mcgown.dev/4cpqd-548"/>
        <updated>2021-12-31T01:16:01.101Z</updated>
        <summary type="html"><![CDATA[Margin 8. Padding 16. Border radius 4. Again.

When I was building React Native interfaces, the irri]]></summary>
        <content type="html"><![CDATA[Margin 8. Padding 16. Border radius 4. Again.

When I was building React Native interfaces, the irritating part wasn't deciding what these values should be. It was repeatedly giving the same little style objects different names.

A small set of typed utility styles makes that work cheaper. Not a new styling language, and certainly not an excuse to build a CSS framework before lunch.
The original video
https://www.youtube.com/embed/bKJNTxoSVtc?showinfo=0&autoplay=0

Updated 10 September 2026: the text below corrects the original bridge/performance explanation and adds the complete generator. Treat the video as historical context where it differs. The code is an illustrative TypeScript implementation, not a device-tested benchmark.
First, a correction about performance
Creating an inline object during render creates a new JavaScript reference. That can matter when a memoized component compares props by reference. It does not mean every identical-looking style necessarily triggers a native repaint.

Current React Native documentation describes StyleSheet.create() as an identity function whose main practical benefit is static type checking. The old explanation that it automatically sends styles across the bridge once, returns numeric IDs, and prevents further renders is not an accurate description of the current API.

Use this approach for consistency, autocomplete, and less repetitive code. If you're claiming a rendering speedup, profile it on the React Native version and devices you're shipping.
Start with a deliberately small vocabulary
We'll generate margin and padding utilities for all sides, horizontal/vertical pairs, and individual sides. Border radius gets its own treatment because “horizontal border radius” isn't a React Native property.

Our values are React Native layout units, not CSS pixel strings:

const intervals = [0, 2, 4, 6, 8, 12, 16, 24, 32] as const;

The as const is important. const prevents rebinding the variable, but doesn't by itself turn an array into a readonly tuple of exact numeric literals. The assertion preserves the union 0 | 2 | 4 | ..., which we can use to construct legal property names.
The complete utility generator
Put this in utilities.ts. It uses TypeScript template literal types and runs once when the module is loaded, not during every component render.

import { StyleSheet, type ViewStyle } from 'react-native';

const intervals = [0, 2, 4, 6, 8, 12, 16, 24, 32] as const;
const families = ['margin', 'padding'] as const;
const directions = [
  '',
  'Horizontal',
  'Vertical',
  'Top',
  'Right',
  'Bottom',
  'Left',
  'Start',
  'End',
] as const;
const radiusProperties = [
  'borderRadius',
  'borderTopLeftRadius',
  'borderTopRightRadius',
  'borderBottomLeftRadius',
  'borderBottomRightRadius',
] as const;

type Interval = (typeof intervals)[number];
type Family = (typeof families)[number];
type Direction = (typeof directions)[number];
type RadiusProperty = (typeof radiusProperties)[number];

type SpacingKey = `${Family}${Direction}${Interval}`;
type RadiusKey = `${RadiusProperty}${Interval}`;
type UtilityKey = SpacingKey | RadiusKey;
type UtilityStyles = Record<UtilityKey, ViewStyle>;

function buildUtilities(): UtilityStyles {
  const result: Partial<UtilityStyles> = {};

  for (const family of families) {
    for (const direction of directions) {
      const property = `${family}${direction}` as const;
      for (const value of intervals) {
        const key: SpacingKey = `${property}${value}`;
        const style: ViewStyle = {};
        style[property] = value;
        result[key] = style;
      }
    }
  }

  for (const property of radiusProperties) {
    for (const value of intervals) {
      const key: RadiusKey = `${property}${value}`;
      const style: ViewStyle = {};
      style[property] = value;
      result[key] = style;
    }
  }

  // The loops above fill every member of UtilityKey.
  // Keep the assertion at this construction boundary.
  return result as UtilityStyles;
}

export const u = StyleSheet.create(buildUtilities());

The result is intentionally boring:

u.marginTop8;       // { marginTop: 8 }
u.padding16;        // { padding: 16 }
u.borderRadius4;    // { borderRadius: 4 }
u.marginStart12;    // Logical spacing for direction-aware layouts.

u.padding7 and u.borderHorizontalRadius4 are not valid keys. That's the useful bit: the editor suggests the vocabulary we've actually chosen.

The final assertion is necessary because TypeScript doesn't prove that nested loops populate a mapped type completely. It isn't runtime validation. Keep the key union and construction loops together, and test the generated map if you change them.

These are module-generated style objects, not build-time-compiled CSS classes. Calling them “precompiled” without that distinction would promise more than the implementation does.
Use utilities for spacing, names for meaning
import { StyleSheet, Text, View } from 'react-native';
import { u } from './utilities';

export function OrderCard({ delayed }: { delayed: boolean }) {
  return (
    <View
      style={[
        styles.card,
        u.padding16,
        u.marginBottom12,
        u.borderRadius8,
        delayed && styles.delayed,
      ]}
    >
      <Text style={styles.title}>An example order</Text>
      <Text style={u.marginTop4}>
        {delayed ? 'Running a little late' : 'On its way'}
      </Text>
    </View>
  );
}

const styles = StyleSheet.create({
  card: {
    backgroundColor: '#ffffff',
    borderWidth: 1,
    borderColor: '#d1d5db',
  },
  delayed: {
    borderColor: '#b45309',
  },
  title: {
    fontSize: 18,
    fontWeight: '600',
    color: '#111827',
  },
});

For the same property, later entries in a style array override earlier ones. style={[u.padding8, u.padding16]} ends up with padding 16. Don't rely on broad and directional shorthand combinations without checking the resulting layout.

The utility objects have stable references, but the inline array above is new on every render. If reference equality actually matters at a memoized boundary, hoist a fixed array or memoize a dynamic one. Don't sprinkle memoization everywhere merely because an array exists.

For bidirectional interfaces, prefer Start and End where you mean leading and trailing space. The physical corner-radius helpers above intentionally describe left and right; extend them with the logical properties supported by your target React Native version if needed.
Check the construction boundary
If you already use Jest with the React Native preset, this small test catches missing combinations and verifies the stored values:

import { StyleSheet } from 'react-native';
import { u } from './utilities';

test('generates the documented utility map', () => {
  // 2 spacing families × 9 directions × 9 values
  // plus 5 radius properties × 9 values.
  expect(Object.keys(u)).toHaveLength(207);
  expect(StyleSheet.flatten(u.marginTop8)).toEqual({ marginTop: 8 });
  expect(StyleSheet.flatten(u.padding16)).toEqual({ padding: 16 });
  expect(StyleSheet.flatten(u.borderRadius4)).toEqual({ borderRadius: 4 });
});

Also run your TypeScript check. A runtime assertion doesn't tell you whether autocomplete rejects a misspelt utility name.
Where I would stop
Spacing and radius are a good fit because their vocabulary is small and predictable. A colour mode, an animated transform, or a responsive layout based on window dimensions needs a different treatment.

Keep dynamic values in component styles or hooks. Use named styles for semantic things such as selected, warning, and card. Don't generate every possible colour, font weight, and layout combination just because the loop is easy to write.

The trade-off is straightforward: every generated utility costs module-initialisation work and memory, including the ones you don't use. A small map is easy to understand. An enormous map is a styling system you now have to maintain.

If a component reads like a shopping receipt of twenty utilities, give it a named style.
Less typing, not more framework
The goal is to stop repeatedly naming { marginTop: 8 }, not to eliminate every custom style in the app. Use a small set of shared values, let TypeScript help you choose them, and spend the saved time on the parts of the interface people actually notice.
Reference
React Native StyleSheet documentation]]></content>
        <author>
            <name>Stewart McGown</name>
            <email>team@ecoeats.uk</email>
            <uri>https://mcgown.dev/author/8702140</uri>
        </author>
        <published>2021-12-31T01:16:01.101Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[Typing Apollo GraphQL Queries with GraphQL-Zeus]]></title>
        <id>https://mcgown.dev/4cpqd-508</id>
        <link href="https://mcgown.dev/4cpqd-508"/>
        <updated>2021-12-28T16:41:41.052Z</updated>
        <summary type="html"><![CDATA[At ecoeats, we used a lot of GraphQL. Customers, riders, restaurants, dashboards: different interfac]]></summary>
        <content type="html"><![CDATA[At ecoeats, we used a lot of GraphQL. Customers, riders, restaurants, dashboards: different interfaces, the same graph underneath.

One of the things that made that manageable was graphql-zeus. It generates TypeScript helpers from your schema, so the editor can complain about your query before a customer does.

The useful trick is that you don't need to replace Apollo Client to use it. Let Zeus describe the operation. Let Apollo handle fetching, caching, and React state.

Updated 10 September 2026: this walkthrough uses the documented Zeus typedGql integration and Apollo Client 4 import paths. It is a new illustrative example, not the original ecoeats client. The snippets have not been run against an application here; pin compatible package versions and type-check the generated output in your project.

[image:c3f9c739-a356-435c-ac80-0d265e9b540b.gif]

Type the operation, not the entire database
A GraphQL Rider type might have twenty fields. A query selecting id and name hasn't fetched the other eighteen.

Manually annotating its result with the whole entity type is a very efficient way to persuade TypeScript that missing data exists. We want the result type to follow the selection, including nullability, and the variables to follow the operation.

That's the job of a TypedDocumentNode: a GraphQL document carrying TypeScript information about its result and variables. Apollo can infer both from it.
Start with a tiny schema
Save this as schema.graphql. It is a fictional delivery schema, not a production API:

type Query {
  rider(id: ID!): Rider
}

type Rider {
  id: ID!
  name: String!
  orders: [Order!]!
}

type Order {
  id: ID!
  title: String!
}

The schema is the generator's input, not a running server. To fetch data, you'll also need a GraphQL endpoint implementing it. In an existing application, use the schema exported by your server.

Install the client dependencies and generator:

npm install @apollo/client graphql rxjs @graphql-typed-document-node/core
npm install --save-dev graphql-zeus
npx zeus schema.graphql ./src --typedDocumentNode

The examples below assume the generated files are at src/zeus/index.ts and src/zeus/typedDocumentNode.ts. Check the generator's output for your installed version and adjust the imports if necessary.

Commit a lockfile. The old Zeus Apollo-specific helpers and current typed-document approach aren't examples to mix together indiscriminately.
Describe the query once
Create src/riderQuery.ts:

import { $, Selector } from './zeus';
import { typedGql } from './zeus/typedDocumentNode';

const orderSummary = Selector('Order')({
  id: true,
  title: true,
});

export const GET_RIDER = typedGql('query')({
  rider: [
    { id: $('id', 'ID!') },
    {
      id: true,
      name: true,
      orders: orderSummary,
    },
  ],
});

Fields without arguments are objects of selections. A field with arguments uses a pair: arguments first, selected fields second.

$('id', 'ID!') declares a variable reference. It isn't the rider's literal ID, and a plain string '$id' isn't a substitute for this helper.

Selector('Order') gives us a reusable, checked selection. It plays a similar composition role to a fragment; it isn't a separate network request.

The resulting operation is conceptually:

query GetRider($id: ID!) {
  rider(id: $id) {
    id
    name
    orders {
      id
      title
    }
  }
}

The handwritten name above is for readability; the generated operation need not use that name. Use your installed Zeus version's operation-naming options if names are required for logging or persisted operations.
Keep Apollo doing Apollo things
Create src/client.ts:

import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client';

export const client = new ApolloClient({
  link: new HttpLink({ uri: '/graphql' }),
  cache: new InMemoryCache(),
});

This assumes /graphql is your application's endpoint or development proxy. Add your application's authentication link if it requires one. Don't put service credentials in browser code.

Now create src/App.tsx:

import { ApolloProvider, useQuery } from '@apollo/client/react';
import { client } from './client';
import { GET_RIDER } from './riderQuery';

function RiderOrders({ riderId }: { riderId: string }) {
  const { data, loading, error } = useQuery(GET_RIDER, {
    variables: { id: riderId },
    errorPolicy: 'none',
  });

  if (loading) return <p role="status">Loading rider...</p>;
  if (error) return <p role="alert">Couldn't load this rider. Try again.</p>;
  if (!data?.rider) return <p>Rider not found.</p>;

  const { rider } = data;

  return (
    <section>
      <h2>{rider.name}</h2>
      {rider.orders.length === 0 ? (
        <p>No orders yet.</p>
      ) : (
        <ul>
          {rider.orders.map(order => (
            <li key={order.id}>{order.title}</li>
          ))}
        </ul>
      )}
    </section>
  );
}

export default function App() {
  return (
    <ApolloProvider client={client}>
      <RiderOrders riderId="demo-rider" />
    </ApolloProvider>
  );
}

Use an ID that exists in your server. Notice what's absent: no handwritten RiderResponse, no cast of the response, and no generic argument on useQuery pretending we know what came back.

The rider lookup is nullable in the schema. That isn't a nuisance to cast away; it is a real state the interface needs to handle.

Apollo's default normalized cache generally identifies objects through __typename plus id or _id. Keep stable identifiers in reusable selections and configure typePolicies for entities with different keys. Typed results don't decide how pagination or list insertion should update the cache.
Prove the types are doing something
Try three deliberate mistakes, then run your project's TypeScript check:

Select titel instead of title. It should be rejected against the generated schema.
Omit the required id variable. The operation should no longer type-check.
Read an unselected field from an order. It shouldn't magically appear in the result type.

Run generation followed by tsc --noEmit in CI. If generated files are committed, also check for unexpected changes after generation. Otherwise, a schema snapshot quietly becoming stale defeats much of the point.
What this doesn't solve
Generated types describe a contract. They don't validate arbitrary network JSON at runtime, prevent a server deployment from breaking old clients, or prove a user has permission to access an entity.

Custom scalars need particular care. A DateTime usually crosses the wire as a string; declaring it to be a JavaScript Date doesn't construct one. Configure scalar mappings and decoding explicitly where needed.

For GraphQL errors, decide whether the screen can display partial data. This example uses errorPolicy: 'none' and treats an operation error as failure. A dashboard that can use partial results needs a different, deliberate UI path.
Is Zeus necessary?
No. Apollo works well with operation types generated from conventional GraphQL documents too. Choose that approach if your team prefers .graphql files and an existing code-generation workflow.

I like Zeus when defining and composing a selection in TypeScript feels natural. The win isn't changing clients. It's removing the gap between the fields I asked for and the fields my editor says I have.

One query, one inferred result shape, considerably fewer lies to TypeScript.


References
Zeus Apollo integration
Zeus CLI generation options
Apollo Client setup and React imports]]></content>
        <author>
            <name>Stewart McGown</name>
            <email>team@ecoeats.uk</email>
            <uri>https://mcgown.dev/author/8702140</uri>
        </author>
        <published>2021-12-28T16:41:41.052Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[NestJS GraphQL WebSockets]]></title>
        <id>https://mcgown.dev/4cpqd-468</id>
        <link href="https://mcgown.dev/4cpqd-468"/>
        <updated>2021-12-28T01:17:05.575Z</updated>
        <summary type="html"><![CDATA[Since the first iteration of ecoeats, I grappled with getting real-time data to customers, riders, m]]></summary>
        <content type="html"><![CDATA[Since the first iteration of ecoeats, I grappled with getting real-time data to customers, riders, merchants, and our operations team. Polling was easy to add. It was also very easy to add everywhere.

This is a retrospective on moving GraphQL traffic onto WebSockets, followed by an updated approach to subscriptions and token expiry. The historical client below records the original trade-off; the newer examples are illustrative integration code, not the original production implementation or a tested application. Updated 10 September 2026.
A million requests a day

A million requests a day. Ouch.
At the time of the original article, we operated in St Andrews, Scotland. Typical daily orders numbered in the hundreds, with 10+ delivery riders and 15+ merchants online at a given time.
So where do all the requests come from? Customers, Riders, Merchants and Admins. Every part of the ecoeats platform that looks real-time uses polling in order to get up-to-date info. From our ops dashboards requesting live capacity information, to Merchants' tablets that auto-refresh the list of orders, to a Rider's phone requesting live route information - it's polling all the way down.
One complication was authentication. A socket can outlive the JWT used to open it. Depending on how connection context and guards are wired, later operations may fail against that expired credential, or an established subscription may retain access longer than intended.

Neither outcome is fixed by simply choosing WebSockets. Connection lifetime and authorization lifetime are different things.
Everything over WebSockets?
Our shortcut was to send most queries and mutations down an established WebSocket, keeping uploads and authentication-related operations on HTTP.

Important distinction: polling over a WebSocket is still polling. This can reduce repeated HTTP framing and related processing, but it doesn't inherently reduce resolver calls, database work, or application-level messages. Modern HTTP connections can be reused too, so don't assume every HTTP request means another TCP handshake.

Here's the historical Ops dashboard configuration. It's included as a record, not current setup instructions: its upload, persisted-query, and subscription packages require compatible versions.

const graphQLClient = new ApolloClient({
    link: ApolloLink.from([
      errorLink,
      createPersistedQueryLink() as any,

      split(
        (operation) => extractFiles(operation).files.size > 0,

        new UploadLink({
          uri: API_URL,
          credentials: "include",
          headers,
        }),

        split(
          (operation) => {
            const isHTTPOnly = [
              "login",
              "me",
              "resetPasswordRequest",
              "logout",
            ].includes(operation.operationName);

            if (
              operation.operationName === "login" ||
              operation.operationName === "logout"
            )
              setTimeout(() => getSubscriptionClient()?.close(), 500);

            return isHTTPOnly;
          },
          new BatchHttpLink({
            uri: API_URL,
            credentials: "include",
            headers,
            batchMax: 3,
          }),
          wsLink
        )
      ),
    ]),
    cache: new InMemoryCache(),
    defaultOptions: {
      query: {
        fetchPolicy: "no-cache",
      },
    },
    name: "admin",
    version: process.env.REACT_APP_GIT_SHA || "dev",
  });
What the historical result shows

  
The historical result showed fewer HTTP requests after moving traffic to WebSockets. It doesn't, on its own, establish a reduction in total server work or prove lower end-to-end latency.
The old setTimeout(..., 500) reconnect logic is also not a lifecycle contract. It can race with login and logout. Tie connection changes to successful authentication transitions instead.
A better default: HTTP for operations, WebSockets for events
For a new application, I'd start with HTTP queries and mutations, then add subscriptions where something genuinely changes in real time. Keep uploads on a supported HTTP path. Only move other operations to a socket after measuring a benefit.

NestJS's Apollo driver supports the graphql-ws library:

import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';

// Add this to your application's module imports.
GraphQLModule.forRoot<ApolloDriverConfig>({
  driver: ApolloDriver,
  autoSchemaFile: true,
  subscriptions: {
    'graphql-ws': { path: '/graphql' },
  },
});

That's transport setup only, not an authenticated server. Add your resolvers and the authorization boundary described below before using it with private data.

Both ends must speak the same protocol. The graphql-ws library implements graphql-transport-ws; the older subscriptions-transport-ws library is not interchangeable with it.

For a browser application using Apollo Client 4, the split looks like this:

import {
  ApolloClient,
  HttpLink,
  InMemoryCache,
  split,
} from '@apollo/client';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { getMainDefinition } from '@apollo/client/utilities';
import { createClient } from 'graphql-ws';

export function createRealtimeClient(
  httpUrl: string,
  wsUrl: string,
  getAccessToken: () => Promise<string>,
) {
  const wsClient = createClient({
    url: wsUrl,
    lazy: true,
    retryAttempts: 5,
    connectionParams: async () => ({
      authorization: `Bearer ${await getAccessToken()}`,
    }),
  });

  const httpLink = new HttpLink({
    uri: httpUrl,
    fetch: async (uri, options) => {
      const headers = new Headers(options?.headers);
      headers.set('authorization', `Bearer ${await getAccessToken()}`);
      return fetch(uri, { ...options, headers });
    },
  });

  const link = split(
    ({ query }) => {
      const definition = getMainDefinition(query);
      return (
        definition.kind === 'OperationDefinition' &&
        definition.operation === 'subscription'
      );
    },
    new GraphQLWsLink(wsClient),
    httpLink,
  );

  return {
    client: new ApolloClient({ link, cache: new InMemoryCache() }),
    wsClient,
  };
}

Supply your application's endpoints and token provider. Use HTTPS/WSS in production. This browser-only example assumes normal JSON HTTP operations, not multipart uploads or server-side rendering.

getAccessToken must return a valid token or fail. If it refreshes credentials, deduplicate concurrent refreshes in that authentication service. Don't start a separate refresh request for every operation.
Token expiry is a lifecycle, not a header
connectionParams runs when connecting. Updating a token in memory does not update an already-open connection.

The simplest refresh strategy is explicit:

Refresh the token through the normal authentication flow before it expires.
After refresh succeeds, restart the socket so the next handshake reads the new token.
Re-establish active subscriptions.
Fetch a fresh snapshot, or resume from a server-supported cursor, to cover the disconnected interval.
If refresh fails, stop subscriptions and return to a signed-out state instead of retrying indefinitely.

For graphql-ws, use its documented graceful-restart recipe when preserving subscriptions across a token refresh. That wrapper closes with a retryable application code and lets the library resubscribe. dispose() is permanent shutdown, not a synonym for restart.

On logout or account switching, unmount protected subscribers, dispose the old socket, stop the old Apollo client, and discard or clear its cache before mounting a fresh authenticated client. Don't automatically replay the old user's subscriptions with the new user's credentials.

On the NestJS side, verify credentials on connection and authorize each operation against the actor and requested resource. The onConnect callback can attach verified identity to extra, and the GraphQL context can expose it to guards. Reject missing credentials and verify JWT signature, expiry, issuer, and audience as appropriate; decoding a JWT isn't verification.

Long-running subscriptions need a revocation policy too. Close connections at credential expiry, revalidate delivery when permissions change, or use another explicit server-side policy. A cooperative browser's refresh timer isn't a security control.
Reconnecting isn't catching up
Imagine an order changes while a merchant's tablet loses connectivity. Reopening the subscription doesn't tell you what happened during the gap.

For a simple dashboard, refetch current state after reconnecting. For event processing, use durable cursors, ordering, and deduplication. Establishing a subscription and fetching a snapshot can itself introduce a race; use revisions or a server-supported resume protocol where missing a transition is unacceptable.

Treat emitted events as committed facts: publish after the database operation succeeds. A multi-instance service also needs shared event distribution; an in-process PubSub won't reach clients connected to another instance.
What I'd test before calling it done
Token expires while the screen is idle and while an operation is active.
A refresh fails, permissions are revoked, or the user switches accounts.
The browser sleeps, the network drops, or the server restarts.
A reconnect occurs between a snapshot and a live event.
Duplicate events arrive or a mutation's response is lost.
Many clients reconnect together and need bounded retries with jitter.

Don't blindly replay mutations after a disconnect. “No response” doesn't mean “not committed”; retries need operation-specific idempotency.

Measure application operations, bytes transferred, resolver/database work, update latency, reconnects, and stale-screen duration. A prettier HTTP chart is nice. A merchant seeing the correct order is the actual goal.
References
NestJS subscriptions and authentication
graphql-ws lifecycle and restart recipes
Apollo Client setup]]></content>
        <author>
            <name>Stewart McGown</name>
            <email>team@ecoeats.uk</email>
            <uri>https://mcgown.dev/author/8702140</uri>
        </author>
        <published>2021-12-28T01:17:05.575Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[Making CRUD feel Euphoric with Dynamic Forms - even in GraphQL]]></title>
        <id>https://mcgown.dev/4cpqd-428</id>
        <link href="https://mcgown.dev/4cpqd-428"/>
        <updated>2021-12-28T01:13:52.860Z</updated>
        <summary type="html"><![CDATA[When I launched ecoeats in February 2020, I chose GraphQL on NestJS for our API. It helped me build ]]></summary>
        <content type="html"><![CDATA[When I launched ecoeats in February 2020, I chose GraphQL on NestJS for our API. It helped me build clients across seven different apps, with graphql-zeus keeping their types in sync with the schema.

Lovely. Except I was still writing the same bloody form over and over again.

GraphQL made fetching data pleasant. It didn't make creating an input, validating it, saving it, and explaining the errors to a human disappear. REST wouldn't have made that work disappear either.

This is a retrospective on the schema-driven forms I built for ecoeats, followed by a small illustrative implementation. The new examples explain the pattern; they aren't a release of the original service or a tested, drop-in application. Updated 10 September 2026.

We begin with a dashboard
It was a requirement from day 1 to be able to see an overview of what went on inside the ecoeats Platform. We needed to know how many orders we had, how many riders were online and whether our merchants had their tablets turned on.
One of the earliest problems I had was the absolute tedium of creating GraphQL endpoints for all of these tasks. Repetitive, uninteresting tables. Create object forms. Edit forms for Rider's birthdays. Each and every one with custom client input validation and server side validation. By the start of 2021 we had a shedload of ObjectTypes, InputTypes and Mutations. So much busywork getting in the way of actually solving the hard problems I wanted to get on with.
You want everything to be configurable when needed, but exposing your (highly trained) support and operations staff to your actual database is never quite the right choice.
There had to be a better way
One option was to serve HTML forms and submit them with a standard <form> element. A perfectly reasonable solution, but it didn't fit neatly into the interactive dashboard and component library we already had.

Then I found JSON Schema, and I was in meta-descriptor heaven.

The idea was simple: describe the editable data once, use that description to render ordinary controls, and leave the actual business operation on the server.
So, what is a dynamic form?
The dynamic form I designed is composed of a few key parts. In GraphQL, the specification looks like this:
[image:fc8fa930-1482-49d6-bda2-133241cf52cc.png]
Most of the form's attributes are regular GraphQL fields. The interesting pair is initialValues, containing the starting data, and schema, describing valid input. A separate uiSchema can suggest widgets and layout without confusing presentation with validation.

Having a JSONSchema that describes an entity allows you to construct an arbitrary display / input method for any instance of that entity. As a JSONSchema property might define something like the below simple name entry;

{
  "type": "object",
  "additionalProperties": false,
  "required": ["name"],
  "properties": {
    "name": {
      "type": "string",
      "title": "Name",
      "minLength": 3,
      "maxLength": 120
    }
  }
}

You can see how this would map on to describe a single piece of JSON such as;

{
  "name": "Hello"
}

Using the schema as a building block for entity representation, I set out to create forms and tables out of this schema and some values alone.

Building dynamic forms in NestJS
The registry
I used custom decorators to register form loaders and submission handlers with a central registry. These were application code, not built-in NestJS decorators. Conceptually, their signatures looked like this:

DynamicFormLoader(() => EntityFunc, FormID, roles)

The loader found the initial values for an entity, if one was supplied. It also checked authorization: being logged in tells us who someone is, not whether they may edit this particular object.

DynamicFormHandler(FormID, { roles })

Adding these decorators to a NestJS service causes them to be hooked in to a global DynamicFormRegistry (we can discuss this another time) at application bootstrap, allowing NestJS to lookup the correct handler for a given FormID when requested from the client side.
Here's an example of a loader/handler pair in our Charity Handling service:

[image:3a64f7ec-45e3-4423-acc0-f8c1b3fb101b.png]
  
Saving arbitrary form input directly to a database would be risky. Generating a schema doesn't remove that risk. The submission path needs its own validation and authorization, regardless of what the browser has already checked.
Generating schemas from validation rules
class-validator-jsonschema connects class-validator metadata to JSON Schema.

This package has made creating JSON Schemas for various forms super easy. The entities used in the above service had their JSON Schema generated directly from the class, like below:
[image:65421248-a326-4835-951d-c8f2d6a990a1.png]
A bit decorator heavy, eh? That historical class also described the charity exposed through GraphQL, which explains the extra @Field decorators. A cleaner boundary is a dedicated input DTO containing only editable fields. TypeScript's Pick or implements alone won't copy runtime validation metadata; the input class still needs its validation decorators.

On submission, class-transformer constructs the input instance and class-validator checks it. Transformation and validation are separate steps: constructing a class does not make its contents safe.

Here's a small NestJS service illustrating that boundary. Its save callback represents an application operation, not an unrestricted repository write:

import { BadRequestException, Injectable } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { IsString, Length, validate } from 'class-validator';

class RenameInput {
  @IsString()
  @Length(3, 120)
  name!: string;
}

@Injectable()
export class RenameFormHandler {
  async submit(
    raw: unknown,
    authorize: () => Promise<void>,
    save: (input: { name: string }) => Promise<void>,
  ): Promise<void> {
    await authorize(); // Must throw if this actor cannot edit this entity.

    if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
      throw new BadRequestException('Expected a form object');
    }

    const input = plainToInstance(RenameInput, raw);
    const errors = await validate(input, {
      whitelist: true,
      forbidNonWhitelisted: true,
      forbidUnknownValues: true,
      validationError: { target: false, value: false },
    });

    if (errors.length > 0) {
      throw new BadRequestException({
        message: 'Check the highlighted fields',
        fields: errors.map(error => ({
          field: error.property,
          messages: Object.values(error.constraints ?? {}),
        })),
      });
    }

    await save({ name: input.name });
  }
}

The registry chooses the handler from a server-owned allowlist. The authenticated actor comes from the request context, never from a submitted userId. The application service must also enforce entity ownership, business invariants, and concurrency rules at the write boundary.

For nested DTOs, validate nested values explicitly and map errors recursively. For JSON Schema generation, check that custom validators and cross-field rules survive the conversion; some rules can only be enforced on the server.
Rendering with react-jsonschema-form
Now we have a schema, how do we turn it into something a person can use? react-jsonschema-form handles ordinary fields and client-side validation. It also supports themes; our dashboard used an antd-based theme.

For a minimal React example, install compatible versions of @rjsf/core, @rjsf/utils, and @rjsf/validator-ajv8. This component receives a loaded form and an application-specific submission function:

import { useState } from 'react';
import Form from '@rjsf/core';
import type { RJSFSchema } from '@rjsf/utils';
import validator from '@rjsf/validator-ajv8';

type Values = { name?: string };
type Definition = {
  id: string;
  schema: RJSFSchema;
  initialValues: Values;
};

export function DynamicForm({
  definition,
  submit,
}: {
  definition: Definition;
  submit: (id: string, values: Values) => Promise<void>;
}) {
  const [values, setValues] = useState(definition.initialValues);
  const [saving, setSaving] = useState(false);
  const [message, setMessage] = useState('');

  return (
    <>
      <Form<Values>
        schema={definition.schema}
        validator={validator}
        formData={values}
        disabled={saving}
        onChange={({ formData }) => setValues(formData ?? {})}
        onSubmit={async ({ formData }) => {
          setSaving(true);
          setMessage('');
          try {
            await submit(definition.id, formData ?? {});
            setMessage('Saved');
          } catch {
            setMessage('Could not save. Your changes are still here.');
          } finally {
            setSaving(false);
          }
        }}
      >
        <button type="submit" disabled={saving}>
          {saving ? 'Saving...' : 'Save'}
        </button>
      </Form>
      <p role="status">{message}</p>
    </>
  );
}

Mount this with a key that changes when switching entity or form version, so one form's local state doesn't leak into another. Keep field-level server errors separate from transport failures and pass them into RJSF's error facilities. The short example above shows only the generic failure path.
The GraphQL boundary
The outer contract remains typed. The schema and values travel through an explicitly registered JSON scalar. For example, a simplified schema could expose:

scalar JSON

type DynamicForm {
  id: ID!
  version: Int!
  title: String!
  schema: JSON!
  initialValues: JSON!
}

type FormResult {
  success: Boolean!
}

type Query {
  dynamicForm(id: ID!, entityId: ID): DynamicForm!
}

type Mutation {
  submitDynamicForm(
    id: ID!
    entityId: ID
    version: Int!
    values: JSON!
  ): FormResult!
}

That SDL is illustrative: implement its resolvers, register the JSON scalar, and connect them to the registry. Neither GraphQL nor NestJS knows what an arbitrary form's JSON should contain without those handlers.

A valid query against that contract looks like this:

query GetDynamicForm($id: ID!, $entityId: ID) {
  dynamicForm(id: $id, entityId: $entityId) {
    id
    version
    title
    schema
    initialValues
  }
}

For example, pass {"id":"MenuOffer","entityId":null} as variables. Submit the edited values with:

mutation SubmitDynamicForm(
  $id: ID!
  $entityId: ID
  $version: Int!
  $values: JSON!
) {
  submitDynamicForm(
    id: $id
    entityId: $entityId
    version: $version
    values: $values
  ) {
    success
  }
}

The server chooses its own schema and checks the submitted version. Never accept the browser's copy of the schema as the validation authority. A schema version prevents silently interpreting old inputs using new rules; entity revisions are a separate concern if concurrent edits could overwrite each other.
A full dynamic form is born
Now we have a gorgeous form generated entirely from our DynamicForm endpoint, we pass it in to RJSF and bam!
This is a screenshot of our Menu Offer creation form that merchants use to create offers for their customers.
[image:cd6f3981-4de9-4b4a-9eec-752125b82c96.png]
Nesting forms without nesting the entire database
A challenge we faced was building forms that referred to other entities. An enum is fine for a small, fixed set of values. It's a poor home for ten thousand menu items with changing permissions.

In the above form, clicking on the 'Restrict to specific items' button opens another nested form, that allows the user to select up to X number of items using the same JSONSchema information from the parent form, including validation options like 'maxItems'.

Our dynamic table shared rendering code with the forms and accepted pagination parameters through the loader. Images could be displayed alongside copyable identifiers.

For this pattern, keep the picker separate from the selected value: store stable IDs in the form, search and paginate options on demand, and recheck every selected ID on submission. A client-side maxItems rule improves feedback; server-side validation enforces it.

GraphQL SDL has no parameterised generic types such as Page<T>. You can generate concrete page types in application code, or use a generic JSON-backed table with an explicit column description. The second option buys flexibility by giving up field-level GraphQL typing. That's a trade-off, not a loophole.

[image:b0b7b084-aa6b-406b-8dfd-96fdb99b45eb.png]
Where this is worth the trouble
Our configurable order-export tools were another good fit. A schema and a handler could replace a bespoke dashboard screen.

But hiding the details inside a JSON field doesn't make an operation private. It still needs authorization, payload limits, audit logging, and abuse protection. It also becomes less discoverable to generated clients.

I'd use this for repetitive admin workflows with ordinary fields and clear business operations. I wouldn't force a carefully designed checkout or a highly interactive editor through it just to avoid writing components.

Before shipping, test rejected unknown fields, invalid nested values, unauthorized entities, deleted relation targets, stale versions, duplicate submissions, and failed saves. Confirm that client and server agree on the JSON Schema dialect and validator behaviour.
Less CRUD, not less responsibility
The satisfying part wasn't making every screen generic. It was making the boring screens cheap, so the unusual ones could receive some actual attention.

I wasn't completely happy with the original nesting API, and this article doesn't promise an open-source release of that implementation. What it does offer is a reusable boundary: the server owns the operation and its rules; the client turns a constrained description into a usable form.

One schema, a small renderer, and considerably less copy-and-paste. That's the euphoric bit.
References
JSON Schema
RJSF setup and validator requirements
class-validator-jsonschema]]></content>
        <author>
            <name>Stewart McGown</name>
            <email>team@ecoeats.uk</email>
            <uri>https://mcgown.dev/author/8702140</uri>
        </author>
        <published>2021-12-28T01:13:52.860Z</published>
    </entry>
</feed>