返回博客

The Question a Java Developer Asked Us

Which design patterns do you use? Our front end has almost no classes in it, so the honest answer took a while. It turned into a good description of how we decide what to build and how we talk to each other about it.

engineeringhow-we-workdesign-patternsproduct

The Question

A developer applying to work with us came from Java. We were talking about coding style, and they asked which object-oriented design patterns we use.

We use most of them constantly, we told them. Strategy for a family of interchangeable behaviors. Observer for a change that needs to reach everything interested in it. Composition over inheritance, as a habit more than a rule.

Their question after that was sharper. How, when our front end declares almost no classes and no interfaces? Java expresses those patterns through a base type and the things that implement it. Our code does not have that vocabulary available, so it has to be doing the same job through something else.

It is. A Vue component already holds what an object holds: its own data, its own methods, and the part a Java object usually hands off elsewhere, how it presents itself. A composable is how one component obtains a behavior from another, the same has-a relationship a class gets from composition, minus the base class. Different vocabulary, same argument, and it held up better than we expected once we sat down to write out exactly how.

The Business Problem Underneath It

We sell software that our customers configure themselves. A public works department builds its own service request forms. A utility defines its own asset categories. A municipality writes its own automation rules for what happens when a report comes in.

That means requests arrive constantly, and they are small. Can we add a signature field to this form. Can we pull data from this other system. Can we send that alert by text message as well as email.

Every one of those is easy to say yes to once. The interesting question is what the tenth one costs. If each new field type means editing the same three files, the tenth request is slower than the first, and the fiftieth is a project. If each one is an addition, the fiftieth costs about what the first did.

Nothing about that is visible in a demo. It is decided years earlier, by how the code is shaped. So we spend real attention on the shape.

Naming the Shape

Most advice about code quality lands on two ideas. Do not repeat yourself. Prefer composition to inheritance. Both are true, and neither settles much at four o'clock on a Thursday when someone is deciding where a new feature goes.

What settles it is a shared name.

When one of us says "make that a registry," everyone on the team knows what arrives in the review: a table with one row per type, one function per row, and a single line that looks up the right row. Nobody sketches it on a whiteboard. Nobody argues about it. The review takes a minute, because the shape was agreed before anyone wrote code.

That vocabulary is the thing we actually got from studying patterns. It lives between people, and it earns its keep twice. It makes a good decision fast, and it makes a poor one visible while it is still a sentence, before it becomes three files.

We keep a written list of the shapes we use and where each belongs. New work starts by naming which one it is. That single question resolves most of the design, and the rest is typing.

What That Buys, Concretely

Take notifications. When something happens that a person needs to know about, we send it. Email, text message, a push to their phone.

A customer asked us to add text messages for one particular alert. That is a small request, and it is exactly the kind we get every week.

We keep one table of what we send, with one entry per notification. Each entry answers a single question in plain code: who should hear about this?

'report.submitted': {
  recipients: (args) => [args.report.assignee, ...args.report.watchers],
},

Adding a notification is one entry. Choosing the channels is a matter of which message templates exist for it, so a text message arrives by writing the text version of the message. The engine that does the sending stays as it is. It is about forty lines long, and it has stayed about forty lines long through every notification we have added.

It did not start out looking like that. The first notification we ever built was a toast, the small message that slides into the corner of the screen to say the thing you just did worked. A toast is about as simple as this gets, and there was a version of the job where we wrote it as a toast and moved on.

We wrote the table instead, because a toast is already answering the two questions everything else would answer. Something happened. Who needs to know, and what should they see?

The answers grew a great deal after that. Some notifications had to reach a person who had closed the tab, so they became email. Some had to reach a crew with no laptop, so they became text messages. Some needed to arrive on a phone at two in the morning, so they became push. Each of those brought real work with it, including a new interface for people to choose what they want to hear about, and none of it changed the answer to the original two questions.

The rule we hold to is that anything unusual about one notification lives inside that notification's own entry. The moment a special case moves into the engine, the engine starts growing a flag per case, and the next person has to read all of them to add anything.

There is a version of this where the sending code asks "is this an email, or a text, or a push" and grows a branch each time. We have both shapes in our codebase, because software written over years is honest about its own history. The branch version is the one we schedule time to convert, and the reason is not tidiness. A row is a Tuesday. A branch is a conversation.

The Java developer would call the first version Strategy. There is no interface and no class anywhere in it, and it does exactly what the pattern promises.

The Object Was Already There

Start with the half of the answer that surprised us least once we looked at it directly. Java expresses an object as a class: a bundle of data, the methods that act on it, and nothing about how it appears on screen, since that is a separate concern handled elsewhere.

A Vue component bundles the same three things, plus the third piece Java usually hands off:

<script setup lang="ts">
const props = defineProps<{ userId: string }>()
const user = ref<User | null>(null)
const isAdmin = computed(() => user.value?.role === 'admin')

async function load() { user.value = await getFetch(`/api/users/${props.userId}`) }
</script>

<template>
  <div>{{ user?.name }} <span v-if="isAdmin">Admin</span></div>
</template>

props is a constructor argument. user and isAdmin are instance variables. load is a method. The template is the part Java usually leaves to something else: the piece that decides how the object presents itself. Put two of these on the same screen for two different users and each holds its own user, private to itself, never seeing the other. That is two objects.

None of that needed the word class. In the patterns we lean on, the object was never really the part in question. What varies is how objects relate to one another: whether one inherits from another, whether a family of behaviors can be swapped out, whether a change in one should reach the others. For the kind of application we build, on the framework we build it in, that relating turned out to work better as composables and tables than as classes. That is a statement about our own practical fit, not a claim that classes stop being useful elsewhere. Ours still exist, in the one place the language requires them, and the next section is that.

Where Inheritance Went

We went looking, out of curiosity, for every place our code uses inheritance. There are a handful, and all of them are error types, where the language requires it.

What we use instead is composition, which in our world looks like small functions that hand back the pieces you asked for.

const { record, canEdit, save } = useRecordEditor(recordId)

That line produces its own working object with its own state. Two editors open at once hold two records and never see each other. A Java developer reads that and recognises an object with public methods, with the rest kept private.

The book that taught most of us these patterns states this as a principle: favour composition over inheritance. Inheritance ties you to a parent and hands you everything it has. Composition takes the three things you need. Vue's own composable style already matches that principle closely, so our code settled here without anyone holding a meeting about it. A team building the same product in Java would still be right to reach for inheritance in places we never do, since the fit depends on the framework as much as the pattern.

When Your Customers Are the Ones Extending It

The pattern we lean on hardest is the one where a change announces itself and anything interested reacts.

Our automation rules work that way, and the interesting part is who writes them. A customer opens the interface and builds a rule: when a report comes in for this kind of asset, notify this crew and open a work order. That rule is a row in their database. When the event fires, the engine finds every rule watching for it and runs each one, keeping failures separate so a broken rule leaves the rest working.

Textbook Observer has programmers registering the listeners. Here the customer does, through a form, on a Tuesday afternoon, with no release involved.

That shift brings problems the textbook never had to solve. A rule can fire an event that triggers another rule, so every run carries a budget for how far a chain may travel. Rules carry their own rate limits, so one busy sensor leaves the queue available to everyone else. Those two safeguards exist because customers, quite reasonably, build things we did not anticipate.

Why We Talk About This

The question we started with was about patterns, and the real answer is about how we work.

We use these ideas constantly and you will not find them by searching for the keywords, because our language spells them differently. More usefully, we have a shared vocabulary for them, so a design decision is usually a short conversation.

The toast is the clearest example. It was written in a language and a framework that barely resemble what runs that code today, by people who had not read the same books. The shape underneath it held anyway, and it is still holding now that the same notification can arrive as an email, a text message or a push at two in the morning.

That is the part worth carrying. The patterns outlast the language used to describe them. Java calls one thing an interface with implementing classes, we call it a table of functions, and the argument both are making is identical. Learn the argument and the syntax stops mattering.

If you are weighing up whether to work with us, on either side of the table, that is what we would want you to know. We think about the shape of a thing before we build it, we write down what we learn, and we go back and fix the places where we got it wrong. What our customers see is that small requests stay small.

Summary

  • Our customers configure the product themselves, so small requests arrive constantly. The cost of the fiftieth one is decided by how the code is shaped, years before it is asked for.
  • The lasting value of design patterns is a shared vocabulary. Agreeing on the shape before the code exists turns a design debate into a short review.
  • A Vue component already bundles what an object bundles: its own data, its own methods, and how it presents itself. That did not need a class keyword, and it is not a claim that classes stop mattering. We still reach for one exactly where the language requires it, and a team building the same product in Java would be right to reach for inheritance in places we do not.
  • We keep behaviour in tables, so a new capability is a row and the engine that reads the table stays as written. Our notification engine has held at about forty lines through every notification we have added.
  • That engine began as a toast in the corner of a screen. Email, text message and push each brought real work, including a new interface for choosing what you hear about, and none of it changed the shape underneath.
  • Inheritance is effectively absent from our front end. Small composable functions hand back the pieces a screen needs, each with its own state.
  • Our automation rules are the customer's own listeners, stored as data. That needs safeguards a textbook version does not: a limit on how far a chain of rules can travel, and a rate limit per rule.
  • We keep a written list of the shapes we use, and revisit the places that predate it.
  • Patterns outlast the language used to describe them. The syntax differs between Java and JavaScript. The argument each pattern is making does not.