In this article, I'm going into the more detailed part of the software development, code architecture.
I have been working on a TypeScript front-end app for browser and electron in the fast-paced gaming industry for 2 years surrounded by brilliant colleagues. It has its perks. It inspired me to develop a concept for cohesive, non-coupled code architecture, for front-end client-side application. Here it is.
The architecture
It's only fair if "the why" is explained before "the how". Feel free to skip to "the how" if you need to.
The Why
Low coupling, high cohesion. The phrase pops up on software development articles and books not without reason.
To change a feature in a program, first, you need to absorb the knowledge in the said code, like "what it does?", "why is it written that way?", "is there any implicit behavior not apparent at first sight?". Identifying those allows developers to accurately make changes to a feature, reducing the chance of regression from the changes' side effects. The more varying the knowledge put closely together, the harder it is to understand, the later the delivery.
Multiplies that with the number of engineers involved in a project. This is why for every N engineer that is involved in a project, only (log N - 1)-ish velocity is being added into the team.
Minimizing this effect can be done by accurately grouping and separating codes so that the more codes relate, the closer they are organized. This is high cohesion. At the same time, codes that don't relate should be separated and should not depend on each other. This is low coupling. Emphasis on the word accurately, if one manages to modularize codes in a manner where it becomes harder to understand, the effort is in vain.
The fact that there are multiple dimensions of relations between codes. It can be how the pattern they are written, the business logic they are handling, the role it is taking, the layer it is placed on, etc. The challenge is to strategically place the codes so that they are predictable, readable, usable, and expendable at the same time. Developers should be able to plug in codes quickly without worrying about side effects and regressions and they should also be able to plug out codes, remove lines, without concurring much damage in the form of regressions.
The How
Look at the image. To the left is how the code should be organized, and to the right is how the objects relate to each other logically on runtime.
Codes should be separated by 1.) scale (e.g. global and local, global, local), 2.) layer placement (e.g. network call layer, logic layer, presentational layer, utilities, helpers), and then 3.) knowledge (e.g. account management, task management, about page, welcome page, etc). These orders are only guides and are not absolute rules, most probably but not always the best practice.
In the image above, codes are grouped by layers. Three kinds of modules are involved: 1.) API calls, 2.) Business Logic, 3.) Presentation Layer. Feel free to add more layers to your app to your liking (e.g. i18n layer, storage adapter layer, codec layer, etc).
There are some constraints that must be enforced on each module:
- API Calls modules should only concern translating fetch/WS calls into business objects.
- Business Logic modules should include data structure, state lifecycle, and actions/state transformer concerning only business logic
- Presentation layer modules should only concern presenting data provided by business logic modules and additional UI-only features.
Cross-function data access and method calls should be minimized between layers. This means no react component, DOM modification components on business logic and API calls, no fetch()
on presentation layers. This is to minimize couplings.
In the image above you can also see EventEmitter
. Imagine EventEmitter
as an entity that can be subscribed based on eventName, "downloadSuccess" for example. Other entities can also trigger the emission of "downloadSuccess" together with a payload
into that EventEmitter
which will trigger subscription functions previously registered by subscribers. EventEmitter should also have type safety definition, meaning that each eventName should have a type definition for its payload. For example "downloadSuccess" would have a payload of string
which indicates the path of the file which had succeeded the download. This allows infinite-way communication between entities that have reference to it.
Suppose a requirement came, one which says "change a select element in the account management page into radio buttons". You're full of other equally important works, and you want to delegate it to a newer developer joining the team a few days ago.
No worries, they'll edit the presentation layer and expect no side effects on the business logic. :D
By now, you might be tempted to group together similarly looking codes to push up the cohesion a.k.a. The Abstraction/The Don't Repeat Yourself. To abstract or not to abstract? Layer separation should be on your mind before abstracting things out of it. Low coupling is more crucial than high cohesion. This order of separation avoids common modules that are annoying but you don't know why. You're not going to need it.
This has been all theory, no real code involved. So here it is.
THE HOW
I'll use these tech stacks to show how the architecture in action:
- Presentational Layer: React
- Business Logic Layer: Unstated, built-in event module
- API Calls Layer: Fetch API
- Structure Definition: io-ts
// /src/modules/dsm-region/models/dsm-region.ts
import * as t from "io-ts"
export const DedicatedServerManagerCodec = t.type({
type: t.keyof({
"stable": null,
"beta": null
}),
id: t.string
});
export type DedicatedServerManager = t.TypeOf<typeof DedicatedServerManagerCodec>;
// The type definition above is the equivalent of
// export type DedicatedServerManager = {
// type: "stable" | "beta",
// id: string,
// }
Above is the code of the definition of DedicatedServerManager. It is an io-ts codec so that it can serve two purposes: type definition and type guard.
// /src/modules/dsm-region/api/dsm-region.ts
import * as t from "io-ts"
import { apiBaseUrl } from "/src/config"
import { DedicatedServerManagerCodec, DedicatedServerManager } from "../models/dsm-region"
export const fetchAvailableDSM = async (): Promise<{ value: DedicatedServerManager[] } | { error: E }> => {
const response = await fetch(new URL("regions/dsms", apiBaseUrl).toString())
.catch(error => ({ error }))
if (response.status < 200 || response.status > 399){
return { error: new APIError() }
}
return response.json()
.catch(error => ({ error: new DecodeError() }))
.then((json) => {
if(!t.array(DedicatedServerManagerCodec).is(json)) {
return { error: new DecodeError() }
}
return { value: json }
})
}
Above is the network layer of the DSM module. As a network layer module, its only concerns are to get and send data via the network. Also, to transfer data correctly it needs to parse it using type guards defined in "../models/dsm-region". It is assured that the consumer of these functions will always either receive the correct type of the data either on the run-time or receive an Error object explicitly, never through exception, which type validation is never properly supported by TypeScript
// /src/modules/dsm-region/dsm-region-logic.ts
import { Container } from "unstated"
import { DedicatedServerManager } from "./models/dsm-region"
import { fetchAvailableDSM } from "./api/dsm-region"
type DSMAvailabilityMap = Map<"stable" | "beta", DedicatedServerManager[]>;
export class DSMRegionPageLogic extends Container<{
isFetching: boolean
dsmMap: null | DSMAvailabilityMap
}>{
events: EventsEmitter<{
fetch: void,
fetchSuccess: void,
fetchError: Error
}> = new EventsEmitter();
state = {
isFetching: false,
dsmMap: null
}
async init(){
try {
if(this.state.isFetching) return;
this.setState({ isFetching: true, dsmMap: null });
this.events.emit("fetch");
const availableDSMs = await fetchAvailableDSM().then(result => {
if(result.error) throw result.error
return result.value
});
const dsmMap = (["stable", "beta"]).reduce((dsmMap: DSMAvailabilityMap, dsmType) => {
dsmMap.set(dsmType, availableDSMs.filter(dsm => dsm.type === dsmType));
return dsmMap;
}, new Map());
await this.setState({ dsmMap })
this.events.emit("fetchSuccess");
} catch(error) {
this.events.emit("fetchError", error);
} finally {
this.setState({ isFetching: false })
}
}
}
Above is the logic part of the DSM module. The logic part of the DSM module is a very simple one. It has two states, isFetching
and dsmMap
. It has one method, which is to fetch the data while at the same time track the fetch process through the isFetching
state.
This logic module has a special EventEmitter
object events
composited in it. It provides a mean of communication between the logic module and its consumer. This logic module broadcasts its events through the events
object to tell the consumer what is happening inside.
// /src/modules/dsm-region/dsm-region.tsx
import * as React from "react";
import { DSMRegionPageLogic } from "./dsm-region-logic"
import { DedicatedServerManager } from "./models/dsm-region"
import ErrorBanner from "src/components/common/ErrorBanner";
import LoadingSpinner from "src/components/common/LoadingSpinner";
import styles from "./dsm-region.scss"
type Props {}
type State {
error: Error | null
}
export default class extends React.Component<Props, State> {
logic = new DSMRegionPageLogic();
state:State = {
error: null
};
componentDidMount(){
// subscribe is unstated's method to listen to state change
this.logic.subscribe(() => this.setState({}));
this.logic.events.subscribe("fetch", () => {
this.setState({ error: null })
})
this.logic.events.subscribe("fetchError", (error) => {
this.setState({ error });
})
}
render(){
const { error } = this.state;
const { dsmMap, isFetching } = this.logic.state
return (
<div className={styles.dsmRegionPage}>
{ error && <ErrorBanner error={error}/> }
{ isFetching && <LoadingSpinner text={"Please wait. Loading DSM data."}/> }
{ dsmMap && (
<div className={styles.dsmSections}>
<DSMSection dsms={dsmMap.get("stable") || null} />
<DSMSection dsms={dsmMap.get("beta") || null} />
</div>
) }
</div>
)
}
}
const DSMSection = ({ dsms }: { dsms: DedicatedServerManager[] | null }) => {
if (dsms == null) return null;
if (dsms.length === 0) return null;
return (
<div className={styles.dsmsSection}>
{dsms.map(dsm => (
<div className={dsm}>
<a href={`/dedicated-server-managers/${dsm.id}`} >{dsm.id}</a>
</div>
))}
</div>
)
}
Above is a page component using DSMRegionPageLogic. It has a very small number of states and a very simple lifecycle thanks to the separation. This page component is allowed to concern only about managing UI states and not care about everything else (network, data/logic lifecycle). The only concern it has is that if the logic object emits an error, which it has to catch and show.
Being decoupled, changes to these components can be easily made. UI changes will not affect logic, Logic changes will not affect the network layer, and so on. It cannot be said that is the same the other way because of the dependency arrow but it helps a lot with that.
These codes above are only a small, simple example of the decoupling. This decoupling that leverages separate lifecycles, state scoping, and event system could be utilized much more. Communication between components from separate VDOM tree becomes possible without redux/flux-like god-object.
Top comments (0)