Patterns in the Age of AI, Part 1: Why Bother with Architecture When AI Writes the Code

Why patterns matter more when AI writes the code, shown with a Pinia store factory that unifies data access over REST, Parse Server and mocks.

12 min read

Code gets generated very fast these days. Advanced models build and debug applications much faster than humans, and sometimes do a better job, too. So the question keeps coming up: why think about architecture and design patterns at all, if a neural network writes the code? Let it sort things out.

I believe the opposite: with generative AI, patterns matter more than ever. In this article I'll explain why, and show a concrete example — a store factory for Pinia that standardizes data access regardless of the backend: a REST API, Parse Server, or mocks in tests. Most of the examples are solutions to real problems I've run into on real projects.

This is the first of two parts. Here I cover why all this is needed, how the architecture works, and how it plays with an AI assistant. The second part is a detailed code walkthrough.

Generating Code Is Cheap. Maintaining It Isn't

Writing code used to be expensive. Now writing it costs almost nothing, but everything else costs as much as it always did: reading, reviewing, debugging, changing it six months later. If a project used to have 10 stores because the team didn't have the bandwidth for 50, now it can easily end up with 50. All of them different.

Here are a few reasons why patterns matter in this situation.

1. AI Is an Amplifier, Not an Architect

A model takes its cues primarily from the code it sees nearby. If a project has five stores and each handles loading and errors its own way, the sixth will be written in a sixth way. Or it will copy the worst of the five. Chaos in a codebase scales exactly like order does. Only faster.

But if the project has a clear pattern, the model will pick it up and reproduce it. A good pattern puts AI on rails.

2. A Pattern Is Compressed Context

To explain to a model how stores work in your project, you can write a page of instructions: how to name fields, how to handle errors, where to cache, how to update a list after creating a record. Or you can write one line: "create the store for a new entity with createStore." The whole agreement is already baked into the code — there's nothing to retell and nothing to forget.

3. Code Review Becomes Feasible Again

Generated code still has to be reviewed. Reviewing 150 lines of unique logic is slow and hard, and by the tenth such store your attention inevitably drifts. Reviewing one factory call plus 20 lines of domain logic on top of it is easy. The less unique code there is, the better the review.

4. A Factory Is Deterministic. Generation Isn't

Ask a model to write the same store twice and you'll get two different stores. A factory returns the same thing every time. Code that doesn't need to be generated can't be generated wrong.

5. A Shared Vocabulary

Pattern names — "factory," "adapter," "repository" — are a shared vocabulary not only for people on a team, but between a human and a model. Saying "put this method in the service, not the store" is much easier when those layers actually exist and are kept apart.

Now a bit about the problems I ran into. I've worked a lot on projects backed by Parse Server. It's a framework that genuinely makes life easier: you get a full-fledged backend with batteries included, and for a startup where every feature was due yesterday, that's worth a lot. But it doesn't make a frontend developer's life any easier, because its philosophy clashes with that of modern reactive frameworks. Instead of plain data, you get a wrapper object and read data through a get method. Making such an object reactive is a bad idea: its data is tucked away inside, so the component template won't update anyway. Stale-template bugs haunted us constantly. I first spotted the idea of a single factory plus a service that returns plain data in a legacy project, still on Vuex and plain JavaScript. I carried it from project to project, moved it to Pinia, and worked out the typing and extension points. Now I'm ready to share it.

The core idea has two parts: a factory that produces uniform stores, and a single class for API services. Adapters for specific APIs can be anything you like, and you can even mix several. It's the most flexible and simple way to bring order to a codebase that I know of.

What does it look like in practice? Very simple.

// src/data/index.ts
export const rest = createRestAdapter('/api')

// src/stores/dishes.ts
export const useDishStore = createStore('dishes', new Service<Dish>('dishes', rest))

// in a component
import { onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useDishStore } from 'src/stores/dishes'

const dishStore = useDishStore()
const { items: dishes, total, loading, error } = storeToRefs(dishStore)

onMounted(() => dishStore.fetchAll())

The Layers of a Frontend App

Let's look at the architecture the factory lives in:

┌─────────────────────────────────────────────────┐
│  Components (UI)          rendering, events     │
├─────────────────────────────────────────────────┤
│  Stores — createStore()   items, current,       │
│                           status, errors, cache │
├─────────────────────────────────────────────────┤
│  Services — class Service list / get /          │
│                           create / update /     │
│                           remove                │
├─────────────────────────────────────────────────┤
│  Adapters                 REST │ Parse │ Memory │
└─────────────────────────────────────────────────┘

Each layer knows only about the layer below it, and only through an interface. A component doesn't make requests, a store doesn't know whether REST or Parse is behind it, a service doesn't know how HTTP works. It's boring — but that boredom is exactly what lets both humans and models know where new code belongs.

The problem is that the store layer is extremely repetitive. Almost every entity — dishes, orders, tables, staff — needs the same things: load a list, load a single record, create, update, delete, show a loading state and an error. Writing this by hand for each entity means copy-paste with small inconsistencies. Generating it with AI means the same copy-paste, just faster.

The solution has four parts: a shared contract, adapters for specific backends, a base service class, and a store factory.

The Contract and the Names

The most important decision in a system like this isn't the code — it's the names. You'll be using them in every file of the project, so I looked at how well-known tools do it:

SourceListSingle recordCreateUpdateDelete
HTTPGETGETPOSTPUT/PATCHDELETE
Rails / Laravelindexshowstoreupdatedestroy
RepositoryfindAllfindByIdsavesavedelete
react-admingetListgetOnecreateupdatedelete

And settled on these rules:

  • Service: list, get, create, update, remove. Data-access verbs, not tied to a transport. post and put give away HTTP, and Parse doesn't expose any HTTP at all.
  • remove, not delete. delete is a reserved word in JavaScript — you can't declare a function with that name.
  • list is paginated from the start. It returns { items, total }. "Load everything" is just list() without a limit — no separate method needed.
  • Store: fetchAll, fetchOne for loading; create, update, remove for changes. If both the service and the store had list, you couldn't tell from store.list whether it's data or an action. The rule is simple: a service method returns data, a store method puts it into state. Mutating methods share the service's names because they perform the same operation and also sync the state.
  • Data are nouns: items, total, current.
  • Status is per operation: fetchingAll, fetchingOne, creating, updating, removing, paired with errors fetchingAllError, fetchingOneError, creatingError, updatingError, removingError. Plus shared loading and error for simple screens.
  • Entities are plain objects with an id field. Not Parse.Object, not model classes. Otherwise backend details leak into components and the abstraction loses its point.

What this contract looks like in TypeScript, and how the factory itself works, I'll cover in detail in the second part.

How It Works with AI

Now for the interesting part. A few lines in the AI assistant's instructions are enough (in my case, CLAUDE.md at the project root):

## Data layer
- Store for any backend entity: `createStore(id, new Service<T>(resource, adapter))`. Never write CRUD by hand.
- Adapters live in `src/data/adapters`. Entities are plain objects with `id`.
- Custom queries are methods on a `Service` subclass, built on `list`/`get`.
- Store methods that need state go into `extend`, each with its own `operation()`. Never wrap a base store in another store.
- Domain logic goes into a separate store composed over the base one.

After that, a request like "add a suppliers list to the admin panel" produces a predictable result: an entity type, a one-line store, a table component. I don't have to check whether the model handled errors properly or forgot to reset loading — that code was written and reviewed once. Review boils down to "is this what I wanted?" rather than "is there a bug in here?"

Even moving to a different backend becomes a one-file task: the model writes a new adapter against the Adapter interface, and TypeScript checks that the contract holds. The rest of the project doesn't change.

Without a system like this, every new entity is 150 lines you have to read in full. And a month later, when you need to change, say, the error format, you'll be fixing it in twenty places instead of one.

Adopting It in an Existing Project

New architecture rarely lands in a greenfield project. Usually there are already a couple dozen stores written at different times by different people, and a couple of APIs, each with its own quirks. Rewriting everything at once isn't an option. The good news: this approach doesn't require a rewrite.

Living Alongside Old Stores

The factory asks nothing of the rest of the code. New stores live next to old ones without conflicts: they get their own data/ prefix in the id. And if an old store is used in dozens of components, you can turn it into a facade over the new one — the public API stays the same, only the implementation changes:

// src/stores/legacy/dishes.ts — the old store; components don't notice a thing
export const useLegacyDishStore = defineStore('dishes', () => {
  const dishes = useDishStore()

  return {
    dishList: computed(() => dishes.items),
    isLoading: computed(() => dishes.loading),
    loadDishes: () => dishes.fetchAll(),
    saveDish: (dish: Dish) => dishes.update(dish.id, dish),
  }
})

Components keep calling loadDishes() and reading dishList, while under the hood the unified contract is already at work, with a shared cache and error handling. This is the classic Facade pattern: it lets you split "migrate the logic" and "migrate the components" into two independent steps.

Multiple APIs in One App

An adapter is bound to a service, not to the app, so different entities can live on different backends:

// src/data/index.ts
export const api = createRestAdapter('/api/v2')
export const legacyApi = createLegacyAdapter('/api/v1')  // the old API with its own response format
export const parse = createParseAdapter(Parse)
export const payments = createRestAdapter('https://payments.example.com')
export const useDishStore = createStore('dishes', new Service<Dish>('dishes', api))
export const useReportStore = createStore('reports', new Service<Report>('reports', legacyApi))
export const useOrderStore = createStore('orders', new Service<Order>('Order', parse))
export const usePaymentStore = createStore('payments', new Service<Payment>('payments', payments))

To components, these four stores are indistinguishable. All the differences — response formats, pagination, error codes — are locked away in the adapters. For example, if the old API returns a list as a bare array without total, its adapter converts the response to { items, total }, and nobody else ever needs to know about that quirk.

Gradual Migration

Together, this gives you a migration plan that doesn't require stopping development. It's essentially the Strangler Fig pattern: the new system gradually grows around the old one until the old one is no longer needed.

  1. Add the data layer. Types, Service, createStore, and adapters for your existing APIs. The old code stays untouched.
  2. New entities go through the factory only. This rule goes into CLAUDE.md, and from that moment the AI assistant stops churning out old-style stores.
  3. Turn old stores into facades. One at a time, starting with the simplest. Each step is small, easy to review, and easy to roll back.
  4. Move components to the new stores. Gradually, whenever you touch them for other tasks.
  5. Delete the facades once nothing references them.

The same mechanism works for switching backends. Moving an entity from the old API to the new one means swapping the adapter in a single line: new Service<Report>('reports', legacyApi) → new Service<Report>('reports', api). The store, the components, and the tests stay the same.

For an AI assistant, this kind of migration is the ideal task format. "Move the orders store to the factory, keeping its public API" is a small, well-scoped task with a clear definition of done, and its result is easy to review. Ten such tasks in a row give you a predictable outcome — unlike one big "rewrite the data layer."

Takeaways

Generative AI hasn't made architecture obsolete — it has raised the stakes. A model replicates good and bad decisions at the same speed. Design patterns — factories, adapters, layers — are how you make sure it's the good ones that get replicated.

The store factory is a small example of this idea. Every new store is one line, a backend switch is one new file, and you can adopt the approach gradually, one entity at a time, without stopping development. It's convenient for me, it's clear to a newcomer on the team, and it puts the AI assistant on rails.

In Part 2 I'll walk through the full code: the TypeScript contract, adapters for REST, Parse Server, and in-memory storage, the factory itself with per-operation status, extending stores with extend, and tests. I'll also take an honest look at how this approach compares to TanStack Query, Pinia Colada, and other off-the-shelf solutions.

Comments

Loading comments…

Leave a comment

Comments appear after a quick review.