DEV Community

Cover image for Design patterns for frontend and pizza - what do they have in common?
NIX United
NIX United

Posted on

Design patterns for frontend and pizza - what do they have in common?

Victoria Tsukan, Frontend Developer at NIX.

You might not have noticed, but in everyday tasks, we often use various patterns. Like a developer's tool, they make our work easier and more efficient, allowing us to write higher-quality code. How exactly? I'll explain further.

In this article, I want to introduce you to common patterns for front-end development and situations where they should be used.

Patterns in IT - what are they?

The word "pattern" refers to a design pattern, a simple solution to a common problem. Patterns can be used at the level of functions, object creation, or architectural design. In a way, these tools resemble mathematical formulas for problem-solving.

The concept of patterns was introduced by architect Christopher Alexander. He noticed that after renovating and arranging homes, his clients often made further adjustments to suit their preferences. By investigating what didn't satisfy people, he identified several patterns - the most suitable positioning of windows and walls, ceiling height, etc. All his findings were compiled in his seminal book, "A Pattern Language."

Some time later, four programmers, inspired by Alexander's book, wrote their own. They were Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides, collectively known as the Gang of Four. Their book, "Design Patterns," describes design patterns for object-oriented systems. The authors identified foundational patterns from which others are derived. It is these patterns that I will explore in the article.

Experts have identified three categories of patterns:

Creational - responsible for object creation.
Structural - designed to describe hierarchy.
Behavioral - define the interaction between elements.

To help you better understand the principle of using patterns, I suggest examining them through the example of... a pizzeria operation.

Creational Patterns

These patterns help create different objects without resorting to copy-pasting while providing flexibility and reusability. There are many patterns in this category, but I'll mention the main ones.

Prototype

This pattern defines a template for creating necessary objects. The prototype allows for the creation of reusable code. In other words, multiple independent objects can be created based on a single template, reducing the required code.

Drawing a parallel with the operation of a pizzeria, this pattern would help automate the pizza-baking process. It would be challenging to manually make a hundred "Margherita" pizzas. However, one could define a formula with ingredients and a pizza recipe, hand it over to a machine, and it would produce 100 copies based on the template.

Implementing this pattern in code is relatively straightforward. The example below demonstrates a pizza prototype object with pizza information. Necessary copies are created using Object.create():

const pizzaPrototype = {
name: "Margarita",

bake: function () {
console.log( "Smells appetizing!" );
}
1

const pizzal
const pizza2
const pizza3d

Object.create( pizzaPrototype );
Object.create( pizzaPrototype );
Object.create( pizzaPrototype );

Enter fullscreen mode Exit fullscreen mode

Factory

Provides an interface for creating objects. It can be compared to a factory with its conveyor belt. When you order a batch of products from a factory, the workers know what components are needed, the assembly process, and the desired outcome. You receive the finished products in the required quantity.

This pattern has several advantages. First and foremost, it maintains independence between the Factory and the objects it produces. It also adheres to the single responsibility principle, where all the logic of object creation resides within the class and is not controlled externally. Additionally, it follows the open-closed principle, meaning that all objects are independent of each other. However, there is a significant drawback: this pattern becomes large and complex when there is a lot of logic involved. It becomes challenging to maintain, especially when creating many objects.

Using the example of pizza, this pattern is quite illustrative. The client chooses their favorite pizza from the menu. The order is then passed on to the kitchen staff, who prepare the dish. The kitchen staff possesses extensive knowledge of how to prepare each type of pizza, ensuring the desired result is delivered.

In the code example, two objects are in the first lines: "Margherita" and "Carbonara" pizzas. They contain specific data such as the pizza's ingredients, size, etc. Next, the Factory is defined with the pizzaClass information, which serves as the pizza configuration. Then, the createPizza method allows you to specify the desired pizza. The Factory associates the pizza name with a specific object or entity that contains all the necessary information. As a result, by passing the pizza name to the createPizza method, the Factory returns the desired object.

function Margarita() {...} // Morgorita dato
function Carbonara() {...} // Corbonara dato

class PizzaFactory {
pizzaClass = Margarita; // Defoult volue

createPizza(pizzaType) {
switch (pizzaType) {
case “margarita”: this.pizzaClass = Margarita; break;
case "carbonara": this.pizzaClass = Carbonara; break;
}
return new this.pizzaClass();
| H

const pizzaFactory = new PizzaFactory();
const margarita = pizzaFactory.createPizza("margarita®);
const carbonara = pizzaFactory.createPizza("carbonara");
Enter fullscreen mode Exit fullscreen mode

Builder

Allows for step-by-step creation of objects, providing control over the process. It also adheres to the single responsibility principle. Although we control the object creation, the logic resides within the pattern. The drawback of the Builder pattern is similar to that of the Factory: if a complex and versatile object is required, maintaining this pattern becomes challenging due to the many methods involved.

In a pizzeria, this pattern would be useful when a customer wants to order a pizza based on their own recipe rather than choosing from the menu. In the code, the PizzaBuilder class is used for this purpose, containing information about the pizza stored in the _pizza variable. Some methods can update this information. In our case, it allows changing the ingredients and the pizza's name.

The illustration demonstrates the implementation of this pattern. We invoke the changeName method and add ingredients one by one:

export class PizzaBuilder {
private _pizza: Pizza = { name: "", ingridients: [] };

addIngridient(ingridient) {
this._pizza.ingridients.push(ingridient);
}

changeName (name) {
this._pizza.name = name;
}
)

const myPizza = new PizzaBuilder();
myPizza.changeName( name “Margarita");
myPizza.addIngridient( ingridient: "tomatoes");
myPizza.addIngridient( ingridient: "cheese");
Enter fullscreen mode Exit fullscreen mode

Structural Patterns

Describe the relationships between multiple object entities. Their goal is to build flexible and efficient systems with a clear hierarchy. Let's discuss some key structural patterns…

Adapter

A widely used pattern that transforms one set of data into another. For example, you receive certain data from the backend, but the format doesn't suit your needs. You require different fields, field names, field quantities, and so on. The Adapter pattern performs the necessary transformation.

This pattern adheres to the principles of single responsibility and open-closed. The logic for the format conversion is separate from the business logic. Adapters can be added or removed independently without affecting other components. As for drawbacks, the use of an Adapter is not always justified. Sometimes it's easier to make changes to the business logic rather than incorporating the pattern.

In a pizzeria, this pattern can be useful in various situations. Let's consider the case of changing a pizza's name. The proposed menu includes fields such as Name, Ingredients, and others. However, custom orders may not have a name field. In the code, this may not be an issue, but when such a pizza is ordered, the corresponding field in the receipt will be empty. This problem can be resolved by modifying the logic to add the Name field.

Returning to our pizzeria example, in the code snippet provided, we have two pizzas: "Margherita" and a custom pizza, one with a name field and the other without it. The Margherita pizza is created using the regular approach with new MargheritaPizza(). For the "nameless" pizza, an adapter is used to set its name. This way, both margarita and adoptedPizza have the same interface. Now, after processing the order, the receipt will display the names correctly:

class MargaritaPizza implements PizzaI {...} // Hos “nome”
class CustomPizza implements CustomPizzal {...} // Hos no “nome”

class PizzaAdapter {
adoptedPizza;
constructor(customPizza: CustomPizzal) {

this.adoptedPizza = customPizza;
this.adoptedPizza.name = ‘Custom’;

const margarita = new MargaritaPizza();
const customPizza = new CustomPizza();
const adoptedPizza = new PizzaAdapter(customPizza).adoptedPizza;

const order = new Order();
order.create([adoptedPizza, margarital);

Enter fullscreen mode Exit fullscreen mode

Decorator

The pattern allows adding functionality to an existing class. It eliminates the need to define a lot of logic within the class itself. Instead, the logic can be extracted to a separate location, described there, and then attached as a decorator. Similar patterns can be easily added or removed if there are too many of them. However, this leads to a drawback. If a decorator is a function that accumulates values and adds its own behavior, removing one decorator becomes challenging when there are many such layered functions. The layers become interdependent. Moreover, decorators can look messy, which requires breaking down all the functions. Overall, this pattern adheres to the single responsibility principle by dividing the logic into classes. Depending on the situation, it can be convenient or introduce difficulties.

Let's consider an example of applying discounts to certain pizzas. You could add a Discount field or logic to all pizza classes in the code, but that would be tedious. It's easier to use decorators. We have a class called SimplePizza, which represents a standard pizza with a getCost method that returns the cost. We also have a decorator called PizzaWithDiscount, which takes a pizza and a discount value. It overrides the getCost method of the pizza and sets a new cost considering the discount:

interface Pizza {...}
class SimplePizza implements Pizza {...}

class PizzaWithDiscount implements Pizza {
protected pizza; discount;
constructor(pizza: Pizza, discount: number) {...}

getCost() {
return this.pizza.getCost() - this.discount;

}

let myPizza = new SimplePizza();

myPizza.getCost(); // simple cost

myPizza = new PizzaWithDiscount(myPizza, discount: 20);
myPizza.getCost(); // simple cost - 20
Enter fullscreen mode Exit fullscreen mode

Also, here we can see the getCost method returning pizza.getCost() minus the discount. The discount is a specific amount, not a percentage. Ultimately, we create a pizza using SimplePizza and check its default value, for example, 100. Then we wrap the pizza in the PizzaWithDiscountdecorator and set a 20% discount. The next time we check the cost, the pattern calculates the discount, overrides the method, and returns the desired result, which is 80.

Behavioral Patterns

These patterns describe the interaction and communication between objects, responsible for distributing responsibilities among entities and parts. They are somewhat similar to algorithms. Although algorithms can also be seen as a kind of pattern, they are focused on computation rather than design. From various behavioral patterns, I will highlight two of them.

Chain of Responsibility

This pattern consists of a chain of handlers. Instead of processing everything in one function, we split it into separate functions, handlers, and classes. As a result, a request goes through multiple stages. It resembles calling a technical support service. Firstly, you reach the first handler "Press this button if you want to talk to an operator." Then you get redirected to the next handler, which is the operator. You explain your technical problem to the operator, and they suggest performing simple operations. If nothing helps, you get redirected to another handler, a technical expert. This way, you go through the entire chain.

In this pattern, the single responsibility principle is compromised. The logic is divided into small parts: logic for performing operations, for calling and managing requests. However, the process may end up with no resolution. For example, if someone calls technical support with an unreasonable request, the operator may terminate the conversation. In that case, the person won't receive a response because the request was initially invalid.

The Chain of Responsibility can have different implementations. In the case of a pizzeria, there is a common scenario. The customer orders a pizza, and the cashier asks clarifying questions like whether the customer wants a salad, dessert, or something to drink. There is a clear chain from one question to another.

To implement this pattern in code, we create two handlers for drinks and salads. There is also an AbstractHandler class that contains the logic with the setNext method. It instructs the current handler about the next one. The askQuestions function acts as the cashier, asking questions and passing them to the handler. Next, we create a handler for drinks called New DrinksHandler and a similar one for salads. We add setNext(salads) for the drinks handler, indicating that after asking about drinks, the cashier should ask about salads:

class DrinksHandler extends AbstractHandler {...}
class SaladsHandler extends AbstractHandler {...}

function askQuestions(handler: Handler) {
const foodToAskList = ['Salads', 'Deserts’', 'Drinks'];
for (const food of foodToAskList) {
console.log( Cashier: Do you want some ${food}?");
console.log(handler.handle(food) || 'No');

const drinks = new DrinksHandler();
const salads = new SaladsHandler();
drinks.setNext(salads);

console.log('Chain: Drinks > Salads\n');
askQuestions (drinks);
console.log('Subchain: Salads\n');
askQuestions(salads);
Enter fullscreen mode Exit fullscreen mode

At the bottom is the implementation. The questions are asked in the correct sequence. The response for drinks and salads will be "Yes." However, if only salads are mentioned, there is no handler specified for them. Therefore, the response for salads will simply be "Yes."

Strategy

This pattern is quite complex and similar to algorithms, where we create separate entities and distribute them among classes. There is a higher-level handler that accepts one of the algorithms, and it becomes the Strategy that we use or switch to another.

Think about routing in Google Maps. Imagine you want to commute from home to work using public transportation. You open the application, enter the start and destination points, and click on the bus icon. The system builds a route according to the specified Strategy.

Suddenly, you decide to walk instead and click on the pedestrian icon. The system changes the Strategy and recalculates the route based on the new criteria.

The main advantage of this pattern is quick system reorientation. You don't need to apply many "if" statements with logic described in one place. This approach also helps with using composition instead of inheritance. Without it, you usually end up with a single generic template algorithm and inherit from it, leading to logic rewriting. In contrast, with composition, all algorithms are independent. Therefore, you can apply the open/closed principle, add something new, and it won't affect the existing code.

Note that this pattern can become cumbersome. I advise against using it if the strategy changes infrequently. It's also important to understand the presence of multiple strategies and the difference between them. To achieve this, carefully design the service interface. In Google Maps, for example, users see icons for buses, pedestrians, or cars and can immediately see which routes are available to them.

Returning to the pizzeria example, this pattern can help build the logic for order fulfillment (delivery or pickup). These Strategies would contain two algorithms and two sets of logic. For delivery, the route would look like this: the courier receives the order, picks up the pizza, delivers it to the customer, and accepts payment. In the case of pickup, the customer places an order, arrives at the establishment at a specific time, pays, receives a receipt, and takes the pizza. If a user's plans change, they can switch between strategies.

class DeliveryStrategy implements ReleaseStrategy {...}
class PickupStrategy implements ReleaseStrategy {...}

class ReleaseOrderSystem {
orderStrategy: ReleaseStrategy;
setStrategy(orderStrategy: ReleaseStrategy) {...}
release() {...}

const releaseOrderSystem = new ReleaseOrderSystem();
'releaseOrderSystem.setStrategy(new DeliveryStrategy());
releaseOrderSystem.release();
releaseOrderSystem.setStrategy (new PickupStrategy());
releaseOrderSystem.release()
Enter fullscreen mode Exit fullscreen mode

There are two strategies with logic that describe the process of getting pizza. There is also a higher-level ReleaseOrderSystem that contains the logic, a setStrategy method for changing the strategy, and a release method for executing the strategy. The bottom part shows how it is used. First, an OrderSystem object is created. Then we set the delivery strategy and call release - and the pizza is delivered. As an alternative, we set the pickup strategy, call release again - and the pickup is completed.

Anti-patterns

These solutions introduce numerous issues with the code. To avoid errors and ensure maintainability, it is important to be aware of common anti-patterns.

Magic numbers:

These are hardcoded variables or values whose origins are not obvious to external observers. For example, in a project involving mathematical calculations, there might be a number multiplied by 2. The original coder understands the source of the number clearly, but another developer may not. This complicates the code review process, making it harder to make changes and work on the product. To address this, it's recommended to use variables or constants instead of magic numbers. Functions can also be named more descriptively to enhance code clarity.

Hard code:

This is a common problem among developers where input data is hardcoded and cannot be easily modified without code editing. If developers and reviewers overlook this factor, serious issues can arise after deployment. It's important to ensure that values are relative rather than absolute to facilitate flexibility and maintainability.

Boat anchor:

This pattern describes entities, functions, or classes that remain in the project "just in case." You might create a powerful, universal feature and use it. However, as the business logic evolves, the feature becomes unnecessary. The logical solution is to remove it, but sometimes developers keep it, hoping that it might be useful in the future and save them development time. Unfortunately, that "future" may never come, and the anchor remains, cluttering the codebase.

Lava flow:

Similar to the previous anti-pattern, in this case, entities remain in the code due to a lack of time for refactoring. Initially, it may not seem like a big deal since the unused fragments don't bother anyone. However, over time, these remnants become like flowing lava, causing chaos. It's worth taking breaks from developing new features and cleaning up the code from outdated elements.

Reinventing the wheel:

This problem is common among beginners who lack experience. They spend a lot of time trying to create something from scratch while there is already a ready-made and tested solution available. It's important to seek advice from colleagues and learn from their experience rather than reinventing the wheel.

Reinventing the square wheel:

This is an even worse version of the previous anti-pattern. Here, not only do you reinvent the wheel, but you also make it worse than existing alternatives. The solution ends up having bugs, inefficiencies and fails to solve the intended problems effectively.

Should patterns be used or not?

There is no definitive answer to whether or not to use patterns. It is important to assess the advantages and disadvantages of different patterns in specific situations. On the one hand, patterns are convenient and reliable. Furthermore, you can learn about the nuances of their implementation and adaptation to your tasks from the relevant community. They are also easily understandable for the team. If you suggest using a specific pattern in a certain part of the code, other developers will immediately see the big picture of how it will work.

On the other hand, patterns do not always fit certain tasks. You need to analyze your capabilities, the capabilities of these patterns, and the efforts required to implement them. It's important to consider the long-term results. There is a saying: when you have a hammer in your hand, everything looks like a nail. After studying and mastering a pattern, there is often a tendency to apply it in almost every project. However, this can lead to additional problems. Therefore, always consider which of your decisions will genuinely simplify the code-writing process.

Top comments (0)