A class in object-oriented programming, such as in JavaScript, Python, and Solidity, serves as a blueprint for creating objects. It provides initial values for state (member variables or attributes) and implementations of behavior (member functions or methods). User-defined objects in Javascript are created using the `class` keyword.
The class defines the nature of an object, while an instance is a specific object created from a particular class. Classes are essential for creating and managing new objects, and they support inheritance — a key aspect of object-oriented programming that facilitates code reuse.
To illustrate this concept, think of a class as a recipe for baking cookies. The recipe tells you the ingredients (like flour, sugar, and chocolate chips) and the steps to bake them (like mixing and baking). This recipe is like a class.
When you follow the recipe to make a batch of cookies, each batch is like an instance of the class. The recipe itself doesn’t change, but you can use it to make as many batches of cookies as you want, each time following the same steps.
In programming, a class provides the template (recipe) for creating objects (batches of cookies). It defines the properties (ingredients) and behaviors (steps) of the objects. For example, in JavaScript or Python, you use a class to define the structure and actions of an object, and then you create individual objects from that class, just like making multiple batches of cookies from the same recipe.
// Define the Cookie class (the recipe)
class Cookie {
// Constructor to initialize the cookie's properties (ingredients)
constructor(flavor, size) {
this.flavor = flavor;
this.size = size;
}
// Method to describe the cookie (steps)
describe() {
console.log(`This is a ${this.size} ${this.flavor} cookie.`);
}
}
// Create instances of the Cookie class (batches of cookies)
const chocolateChipCookie = new Cookie('chocolate chip', 'large');
const sugarCookie = new Cookie('sugar', 'small');
// Use the describe method to describe each cookie
chocolateChipCookie.describe(); // Output: This is a large chocolate chip cookie.
sugarCookie.describe(); // Output: This is a small sugar cookie.
Classes are important in Node.js server applications because they encourage modularity, reusability, and code organization, making complex systems easier to maintain. Encapsulation is a crucial aspect of object-oriented programming that ensures data integrity and enhances code security. By encapsulating data within classes and controlling access through methods, sensitive information can be protected and manipulated in a controlled manner.
Classes use abstraction to separate the interface of an object from its internal implementation. They define the external behavior of an object and encapsulate its internal workings. This allows developers to interact with objects based on their intended behavior without needing to understand the details of how that behavior is achieved. In other words, abstraction enables the hiding of the internal details of a class from the outside, allowing developers to focus on what the class does rather than how it is implemented.
In server applications, higher level modules such as controllers may depend on lower level services, to implement the APIs business logic. This degree of interdependence is referred to as a dependency or coupling. Let’s say we have two classes, class A and class B, class A being the higher level module using a lower level module, class B we can say class A depends on class B.
class B {
}
class A {
private b = new B();
}
// class A depends on class B
Loose coupling, characterized by minimal interdependence between components or modules of a system, is a hallmark of a well-structured application. When high-level modules depend on abstractions, it promotes loose coupling, making it easier to change the implementation of low-level modules without affecting the high-level modules. Conversely, a tightly coupled system, where components are dependent on one another, is not ideal.
One negative aspect of a tightly coupled system is that changes in one module can have a ripple effect on other modules that depend on the modified module. Additionally, a tightly coupled module becomes challenging to reuse and test because its dependent modules must be included.
One way to achieve loose coupling is through Dependency Injection. Dependency Injection is a specific technique to implement Dependency Inversion by injecting dependencies from the outside rather than creating them internally. Dependency Inversion is the fifth part of the SOLID principles, which are guidelines that help you design more maintainable and scalable software. The Dependency Inversion principle states that high-level modules should depend on abstractions rather than on low-level modules.
In Nodejs Express applications, Dependency injection can be archived using libraries like typedi. Alternatively, developers can use frameworks like NestJS, which offer native support for dependency injection, further simplifying the development of maintainable and scalable applications.
// Low-level module
import { Service } from "typedi";
@Service()
class DemoService {
hello(name: string) {
return `Hello, ${name}`;
}
}
export default DemoService;
DemoService is considered a low-level module because it provides specific functionality — in this case, generating a greeting message. It does not depend on higher-level modules and is focused on performing a singular, well-defined task. The @Service() decorator from the typedi library marks the class for dependency injection. This allows DemoService to be easily injected into other components or services that depend on its functionality, promoting loose coupling. By using dependency injection, higher-level modules can depend on the abstraction provided by DemoService without needing to know its internal implementation details. This aligns with the Dependency Inversion principle, which states that high-level modules should depend on abstractions rather than on low-level module implementations.
This setup allows for easier maintenance and testing since the DemoService can be replaced or modified without impacting the modules that use it, as long as the interface remains consistent.
// High-level module
import { Request, Response } from "express";
import DemoService from "../services/demo.service";
import { Service } from "typedi";
@Service()
class DemoController {
constructor(private readonly demoService: DemoService) {}
hello = (req: Request, res: Response) => {
const param = req.query.param as string;
if (param) {
const response = new DemoService().hello(param);
return res.send(response);
}
return res.status(401).send("param not found");
};
hi = (req: Request, res: Response) => {
const param = req.query.param as string;
if (param) {
const response = this.demoService.hello(param);
return res.send(response);
}
return res.status(401).send("param not found");
};
}
DemoController is a high-level module responsible for handling HTTP requests and generating appropriate responses. It uses the services provided by DemoService to fulfill its responsibilities. The hi method demonstrates loose coupling. It uses dependency injection to receive an instance of DemoService via the constructor. This approach allows DemoService to be easily replaced or mocked in testing without changing the DemoController code. The DemoController depends on an abstraction rather than directly instantiating DemoService, promoting flexibility and easier maintenance.The hello method demonstrates tight coupling. It directly creates a new instance of DemoService within the method.This approach makes DemoController tightly coupled to DemoService, making it harder to replace or mock DemoService in testing or if its implementation changes. Any modification to DemoService could directly impact DemoController.
In summary, the hi method exemplifies loose coupling by relying on dependency injection, which aligns with the principles of scalable and maintainable software design. In contrast, the hello method shows tight coupling by directly instantiating DemoService, leading to less flexible and harder-to-maintain code.
In addition to adhering to efficient software architecture principles, loosely coupled high-level modules tend to be more performant than tightly coupled ones. Using the provided `DemoController` as an example, the `hi` API was a few milliseconds faster than the `hello` API. This performance difference is due to the loose coupling in the `hi` method, which uses dependency injection to receive an instance of `DemoService`. In contrast, the `hello` method is tightly coupled, as it instantiates `DemoService` every time the API is called.
In the `hi` method, the `DemoService` instance is injected through the constructor and reused, avoiding the overhead of creating a new instance with each request. This not only improves performance but also enhances testability and maintainability by allowing the service to be easily mocked or replaced.
On the other hand, the `hello` method directly instantiates `DemoService` within the method. This approach results in a performance penalty due to the repeated instantiation of the service. Moreover, this tight coupling makes the code harder to maintain and test, as any changes to `DemoService` would necessitate changes to `DemoController`.
Overall, the loosely coupled `hi` method demonstrates the benefits of using dependency injection, resulting in a more efficient, maintainable, and testable architecture compared to the tightly coupled `hello` method.