gib's blog

Programming in SOLID: An Art

They use to say that programming is not only making the product work, but also making the code good. This is in fact true, as better code makes further advancements in the source code easier. Other than that, it makes debugging errors easier and doable. A popular principle to hold on to while coding is called the SOLID principle, with each letter representing a principle that needs to be hold.

S: Single Responsibility Principle (SRP)

Single Responsibility Principle is a way of implementing separation of concerns in our codebase. This principle makes sure that a certain component only does one thing. To get a better understanding on how this is implemented, I'll use the send verification email use case.

In this use case, we need to:

I'll delegate the first point to the auth service, because it's still auth related:

class AuthService {
...
   async verifyEmail(dto: EmailTokenQueryDto) {
       const user = await this.usersService.getUserById(dto.userId);
       if (user.emailIsVerified) {
           throw new BadRequestException(
              `Email ${user.email} is already verified`,
           );
       }
      await this.emailTokenService.validateToken({
        ...dto,
        purpose: 'EMAIL_VERIFICATION',
      });

      const result = await tx
        .update(users)
        .set({ emailIsVerified: true })
        .where(eq(users.id, dto.userId))
        .returning({ email: users.email });

      const email = result[0].email;

      return email;
   }
}

We will let the email token service to do the token validation part:

class EmailTokenService {
...
   async validateToken({ token, userId, purpose }: ValidateTokenInterface) {
       const result = await this.db
         .select()
         .from(emailTokens)
         .where(
           and(
             eq(emailTokens.id, token),
             eq(emailTokens.userId, userId),
             eq(emailTokens.purpose, purpose),
           ),
         );

       const existingToken = result[0];

       if (!existingToken) {
         throw new BadRequestException(`Invalid token`);
       }

       if (isPast(existingToken.expiryTime) || !existingToken.isValid) {
         throw new BadRequestException('Token is expired');
       }

       await this.setTokenAsInvalid(token);
      }
}

By doing this, we are applying SRP to our codebase. It keeps everything in its place and makes the code cleaner.

O: Open/Closed Principle (OCP)

I really like this principle. To apply this principle, one must make sure that a component in the codebase must be open for extension, but close for modification. What does this mean? Let's use TypeScript interfaces to explain this principle.

In the frontend part of the codebase, it is very common to hit the backend. The backend will return some data with some response body. Most of our team's return data is formatted in this way:

responseMessage: string
responseStatus: 'SUCCESS' | 'FAILED'
responseCode: HttpStatus

We can then create a base interface for this:

interface BaseAPIResponseInterface {
   responseMessage: string
   responseStatus: 'SUCCESS' | 'FAILED'
   responseCode: HttpStatus
}

And now let's say we want to get some data, for instance events, from the backend. All we need to do is define another interface that extends the base interface like this:

interface GetEventsResponseInterface extends BaseAPIResponseInterface {
   events: EventInterface[]
   pagination: PaginationInterface
}

In our frontend codebase, we use a custom hook called useAxios to make API calls. It's a generic hook, so we can pass in interfaces when using it like this:

useAxios<GetEventsResponseInterface>({
    fetchOnRender: true,
    method: "get",
    url: "api/events",
    config: {
      params: {
        search: searchQuery,
        page: currentPage,
        limit: 24,
        sortOrder: "asc",
        priceLower,
        priceUpper,
        startDate,
        endDate,
        topics: selectedTags,
      },
    },
    callback: {
      onSuccess(data) {
        // the data will be automatically typed into the proper type with the interface made above
        setEvents(data.events);
        setPagination(data.pagination);
      },
      onError() {
        toast("An error occurred while getting events. Please try again.");
      },
    },
    dependencies: [searchParams],
  });

When we want to make another API call, for instance to get the current user data, we can make another interface based on BaseAPIResponseInterface and we can use it in the useAxios hook. By doing this, we are applying Open/Closed Principle, as we are not modifying the base API response interface, but rather we are making new interfaces for new API call use cases. I think this principle is pretty cool.

L: Liskov Substitution Principle (LSP)

To apply this principle, one must make sure that the functionalities of a parent class can be replaced by its child classes. Let's go to React for the example.

In our project, we use shadcn for some of our components. One of the components we use is Button, with the following code:

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean;
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, asChild = false, ...props }, ref) => {
    const Comp = asChild ? Slot : "button";
    return (
      <Comp
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      />
    );
  }
);

An important thing to note is the fact that the props of the Button element extends React.ButtonHTMLAttributes<HTMLButtonElement>, which basically means this is another button in the HTML, just with extras like classNames and other props. This child button element can replace the functionality of a standard button element in the HTML page, fulfilling the Liskov Substitution Principle.

This source is an inspiration for the LSP explanation and example.

I: Interface Segregation Principle (ISP)

To apply this principle, one must make sure that a component in the codebase does not have to implement a method or an attribute that it does not need.

In the context of a React component, this means that all of the props sent to a component must be used. To do this, we need to make sure that the props is properly purposed for the component at hand.

Take for instance an events pagination component. Although we get the pagination data from making an API call for getting the events, the pagination component itself does not need to know the interface for the event data.

export interface EventInterface {
  id: string;
  title: string;
  ...
}

export interface PaginationInterface {
  totalPages: number;
  currentPage: number;
  hasPrev: boolean;
  hasNext: boolean;
}

// although we have these interfaces, we only need the PaginationInterface

export interface EventsPaginationProps {
  pagination: PaginationInterface;
  currentPage: number;
}

We'll use this interface in a component called EventsPagination:

import {
  Pagination,
  PaginationContent,
  PaginationItem,
  PaginationLink,
  PaginationNext,
  PaginationPrevious,
} from "@/components/ui/pagination";
import { EventsPaginationProps } from "./interface";

export const EventsPagination: React.FC<EventsPaginationProps> = ({
  pagination,
  currentPage,
}) => {
  return (
    <Pagination className="mt-8">
      <PaginationContent data-testid="events-pagination">
        <PaginationItem>
          <PaginationPrevious
            data-testid="events-pagination-previous"
            href={`?page=${pagination.currentPage - 1}`}
            className={`${!pagination.hasPrev && "pointer-events-none text-gray-300"}`}
          />
        </PaginationItem>
        <PaginationItem
          data-testid="events-pagination-item"
          className="flex items-center gap-2"
        >
          {Array.from({ length: 3 }, (_, i) => {
            const page = pagination.currentPage + i - 1;
            return (
              page > 0 &&
              page <= pagination.totalPages && (
                <PaginationLink
                  key={page}
                  href={`?page=${page}`}
                  className={`${currentPage === page && "bg-purple-200"}`}
                >
                  {page}
                </PaginationLink>
              )
            );
          })}
        </PaginationItem>
        <PaginationItem>
          <PaginationNext
            data-testid="events-pagination-next"
            href={`?page=${pagination.currentPage + 1}`}
            className={`${!pagination.hasNext && "pointer-events-none text-gray-300"}`}
          />
        </PaginationItem>
      </PaginationContent>
    </Pagination>
  );
};

The EventsPagination uses all the props given, which means the component fulfills the Interface Segregation Principle.

D: Dependency Inversion Principle (DIP)

To apply this principle, one must make sure that high level modules depend on abstraction instead of concrete implementations. To get a better understanding of this principle, let's head back to the code in NestJS.

I have this class called MailService :

@Injectable()
export class MailService {
  constructor(
    @Inject(PG_CONNECTION) private db: DbType,
    private emailTokenService: EmailTokenService,
    private mailerService: MailerService,
  ) {}

  ...

This class has a dependency called MailerService (MailService is a custom service class, while MailerService is a service from @nestjs-modules/mailer). MailService relies on an abstraction because MailerService is from MailerModule, which is setup like this:

import { MailerModule } from '@nestjs-modules/mailer';
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
import { Module } from '@nestjs/common';
import { join } from 'path';
import { EmailTokenModule } from 'src/email-token/email-token.module';
import { MailService } from './mail.service';

@Module({
  imports: [
    EmailTokenModule,
    MailerModule.forRoot({
      transport: {
        host: process.env.MAIL_HOST,
        port: Number(process.env.MAIL_PORT),
        auth: {
          user: process.env.MAIL_USER,
          pass: process.env.MAIL_PASSWORD,
        },
      },
      defaults: {
        from: process.env.MAIL_SOURCE,
      },
      template: {
        dir: join('/app/dist/mail/templates'),
        adapter: new HandlebarsAdapter(),
      },
    }),
  ],
  providers: [MailService],
  exports: [MailService],
})
export class MailModule {}

In this case, our team uses Brevo for sending emails, but we can change our environment variables to migrate to another external mailer service, without having to change our implementations in MailService. This means that MailService fulfills the Dependency Inversion Principle, giving flexibility for change in the usage of external mailer service.

This source is an inspiration for the DIP explanation and example.

Conclusion

SOLID principle is very beneficial for the refinement of one's codebase. It turns out that there are a lot of ways to fulfill these principles and that is what makes it an art.