Frontend development and Object-Oriented Programming are closer than they might seem. The SOLID principles and Clean Architecture can be used to create frontend applications and they provide convenient tools for the development.
In this series of 3 posts, we will create an image generator that will draw trees on canvas
. We will accent the architecture and write the code in compliance with OOP principles.
I will show the basics of system design and examples of using TypeScript for writing code in the OOP style.
As a result, we will create an application that will draw images like this one:
Prerequisites
In this post, I suppose you know the basics of OOP, the difference between classes and interfaces, and how dependency injection works.
I've got a post about dependency injection. It might be worth reading for a better understanding of DI and DI-containers.
Also, we will refer to SOLID principles. You don't need to know them in detail but it's better if you've heard something about them.
What We'll Cover in the First Part
In this post, we're going to design the application architecture using Clean Architecture principles and Domain Driven Design. Then, we will set up the dev-environment. In the end, we will write the code for one of the domain layer modules.
Let's begin with the design!
What We're Going to Need
We want to split responsibilities between different modules as it is advised in Single Responsibility Principle, SRP. Let's define what problems we need to solve.
L-System Module
For generating the basics of the tree, we're going to use L-Systems. Those are sets of entities and rules that describe how those entities transform over time.
Trees are fractals, and L-Systems are a convenient mathematical model for describing fractals. So, the first module we're going to need is an L-System generator.
It will create a set of transforming characters like this:
1st recursion: 1[0]0
2nd recursion: 11[1[0]0]1[0]0
3rd recursion: 1111[11[1[0]0]1[0]0]11[1[0]0]1[0]0
We will take those characters and interpret them as commands for drawing on canvas
.
Geometry Module
We will use the Pythagoras tree as a basic fractal. In the end, we will add some randomness to make it look more like a real tree.
For drawing it, we're going to need to know where on canvas to draw lines and what length those lines should have. The geometry module will handle these calculations.
L-System Interpretation, Graphics, and DOM Modules
For showing a generated image, we're going to need the access to the DOM and a canvas
element. Also, we will need a “translator” from L-System characters language to the drawing commands language.
Okay, we now determined all the tasks and problems. Let's design the relationships between modules.
Application Architecture
When I need to design an architecture I like to re-read this post: DDD, Hexagonal, Onion, Clean, CQRS, …How I put it all together.
I like to use it as a guide. I take principles and heuristics from it, and apply them to my code.
The first thing to do is to split the application into layers:
- domain,
- application (including ports),
- adapters layer.
Domain Layer
This layer contains business-logic. The code and the data in the domain layer make an application different from other applications. Modules in this layer should not have any dependencies.
In our case, the domain layer contains L-Systems and Geometry modules.
Application Layer
This layer contains code and rules specific to this particular application. In our case, the application layer contains an interpreter that “translates” L-System characters into drawing commands.
The difference between the domain layer and the application layer is similar to the difference between a melody and the playing manner. The melody is written in notes and doesn't change (domain). The accompaniment, tempo, timbre, and so on depend on the situation and the mood (application).
Modules in the application layer depend only on the domain layer.
Application layer also contains ports. Ports are specifications for external services how they can connect to our application. It describes how our application wants to use or be used by external services.
Ports satisfy application needs. If an external service interface is incompatible with application needs we create an adapter.
Adapters Layer
An adapter makes an external service interface compatible with our application.
In our case, a port is an application entry point. It tells how the application can be used.
Adapters, in our case, are modules for working with the DOM and accessing canvas
.
Combine Layers
Let's draw an architecture diagram:
Notice that dependencies' direction is towards the domain. It means that outmost layers depend on inmost and never otherwise.
Why divide code in such a way?
- The most important code (the domain) can be transferred from one project to another without modifications since it doesn't depend on anything.
- If the UI is changed (e.g. we change
canvas
tosvg
) we will only need to replace UI adapters and nothing else. - It's more convenient to split the application in packages that can be deployed independently.
Okay, we designed the app. Let's set up the environment.
Setting Up the Environment and DI
For initial setup, I used createapp.dev.
All the configs and settings you can find in the project repo on GitHub.
For setting up the dependency injection (DI), we will use wessberg/DI
as a DI-container and wessberg/di-compiler
to resolve all the dependencies at compile-time.
Let's add DI configs in webpack.config.js
:
// webpack.config.js
const { di } = require("@wessberg/di-compiler");
// …
rules: [
{
test: /\.ts$/,
use: [
{
loader: "ts-loader",
options: {
getCustomTransformers: (program) => di({ program }),
},
},
],
},
],
// …
The custom di
transformer replaces interfaces with according class instances. In the future, we will explicitly define which class should implement a particular interface.
Then, create the container itself:
// `src/composition/core.ts`
import { DIContainer } from "@wessberg/di";
export const container = new DIContainer();
The container
object will be used for interface registrations.
Writing Domain Layer
As we remember, the l-system
module generates L-System characters using specific rules.
First, we will define a public API of this module, then implement it, and finally, register the module in the DI container.
Defining Public API
We create an interface SystemBuilder
. This interface will be the entry point to this module:
// src/l-system/types.ts
export type SystemSettings = {
rules: RuleSet;
initiator: Axiom;
iterations: IterationsCount;
};
export interface SystemBuilder {
build(settings: SystemSettings): Expression;
}
All the modules that somehow use L-Systems will depend on the SystemBuilder
interface and only on it. I will explain the reasons why a bit later 🙂
The Axiom
, RuleSet
, IterationsCount
, and Expression
types are type-aliases that describe module entities and rules:
-
Axiom
, is a starting character of an L-System; -
RuleSet
, is a set of rules that describe characters transformation; -
IterationsCount
, how many times we need to transform characters; -
Expression
, is the final string of characters.
We make all these types globally accessible via type annotations:
// typings/l-system.d.ts
type Combined<TCharacter> = TCharacter;
type Transformed<TExpression> = TExpression;
type Character = string;
type Variable = Character;
type Constant = Character;
type Expression = Combined<Variable | Constant>;
type RuleSet = Record<Expression, Transformed<Expression>>;
type Axiom = Variable;
type SystemState = Expression;
type IterationsCount = number;
Now, we can start implementing the logic.
SystemBuilder
Implementation
For implementation, we will create a class:
// src/l-system/implementation.ts
import { SystemBuilder, SystemSettings } from "./types";
export class Builder implements SystemBuilder {
public build(settings: SystemSettings): Expression {
// …
}
}
When creating a class, we define that it implements SystemBuilder
. The implements
keyword tells a compiler to make sure that the class contains all the public methods and properties defined in the interface.
An interface is a behavior contract. It describes how this module can be used. It guarantees that a module has defined methods and properties.
Other modules don't need to know the implementation details. They only need to be sure that they can call SystemBuilder.build
and get the result.
The interface becomes the only input that an external world can communicate to the module:
This makes the code less coupled, and hence more replaceable.
Let's get back to the implementation. To create the Pythagoras tree we're going to need:
- 2 variables:
"0"
и"1"
; - 2 constants:
[
и]
; - an axiom (initial character):
"0"
; - and transformation rules:
"1" → "11"
,"0" → "1[0]0"
.
So we expect that starting character "0"
will be transforming into:
- 1st iteration:
"1[0]0"
; - then:
"11[1[0]0]1[0]0"
; - then:
"1111[11[1[0]0]1[0]0]11[1[0]0]1[0]0"
; - and so on...
We're going to need a local state. It will keep all the characters for a current iteration. Let's use a private field called state
for it.
Extract an axiom, rules, and iteration count from the argument. In every iteration apply the rules:
// src/l-system/implementation.ts
import { SystemBuilder, SystemSettings } from "./types";
export class Builder implements SystemBuilder {
private state: SystemState = "";
public build({ axiom, rules, iterations }: SystemSettings): Expression {
this.state = axiom;
for (let i = 0; i < iterations; i++) {
this.applyRules(rules);
}
return this.state;
}
}
In our case, applying the rules means replacing each character in the state
with those from the rules set:
// src/l-system/implementation.ts
// …
private applyRules(rules: RuleSet): void {
const characters: List<Character> = this.state.split("");
this.state = "";
for (const character of characters) {
const addition = rules[character] ?? character;
this.state += addition;
}
}
// …
Notice that the applyRules
method is private. External modules won't be able to access it directly. We expose only methods defined in the SystemBuilder
interface.
All the transformation details are encapsulated inside of this module.
Now, let's register this module in the container and try to run the application.
Registering Module in DI Container
Usually, for creating class instances we use the new
keyword:
const builder = new Builder();
// builder.build();
In our case, we will use a slightly different approach.
// src/l-system/composition.ts
// Importing the DI-container:
import { container } from "../composition";
// ...the interface:
import { SystemBuilder } from "./types";
// ...and the implementation:
import { Builder } from "./implementation";
container.registerSingleton<SystemBuilder, Builder>();
On the last line, we tell the container to return an instance of the Builder
class when it's asked to give something that implements the SystemBuilder
interface.
Remember, all the modules that depend on L-Systems refer to the SystemBuilder
interface. They “ask” the container to give them something that implements it. When this happens the container returns the Builder
instance.
So, if we need to change the implementation, we change only the Builder
class and nothing else. As long as the SystemBuilder
interface stays the same no external modules are affected.
Okay, to complete the registration we need to import it into composition/index.ts
:
// src/composition/index.ts
import { container } from "./core";
import "../l-system/composition";
export { container };
Wait, What's a Singleton?
Here, singleton
refers to the object lifetime type. In general, registerSingleton
creates at most one instance of the registered service, and the clients will always receive that same instance from the container.
I explained it in a bit more detail in the post about DI.
Well, Okay But How to Use It?
Fair question 😃
Indeed, we cannot access the builder
right away. We need to ask the container to give it to us:
// src/index.ts
import { container } from "./composition";
import { SystemBuilder } from "./l-system/types";
const builder = container.get<SystemBuilder>();
console.log(
builder.build({
axiom: "0",
iterations: 3,
rules: { "1": "11", "0": "1[0]0" },
}),
);
// 1111[11[1[0]0]1[0]0]11[1[0]0]1[0]0
In the code above, we ask the container to give us something that implements SystemBuilder
. Earlier, we registered the Builder
class as the implementation of SystemBuilder
:
container.registerSingleton<SystemBuilder, Builder>();
So, the container will give us an instance of this class. Again, we don't know anything about implementation details, we access only the interface.
Let's run the app and check if everything works properly:
Everything works! 🥳
In the Next Posts
In the second part, we will create the geometry module and the DOM adapter. In the end, we will display our first image on the `canvas.
In the last part, we will write a “translator” for L-System characters. Also, we will generate the Pythagoras tree and add some randomness to make it look more like a real tree.
Sources
Application sources and example:
L-Systems, fractals:
Architecture, OOP, DI:
- Clean Architecture, R. Martin
- Domain Driven Design
- DDD, Hexagonal, Onion, Clean, CQRS, …How I put it all together
- Setting Up Dependency Injection with TypeScript in an Object-Oriented Way
- Encapsulation.
- Object Lifetime
SOLID principles:
Top comments (2)
perfect
Thank you! 😊