When working on a project, integrating external libraries is often necessary. However, tightly coupling these libraries with the code can make it difficult to swap them out or refactor without risking regressions. That’s why I started using the Adapter Pattern—it hides external dependencies, allowing seamless library replacement without breaking the rest of the application.
My application code dealt directly with momentjs and was tightly coupled to it. Since momentjs was deprecated, I wanted to switch to another library: dayjs. Switching to a new library meant, not only finding and updating the references but also retesting every functionality the refactor impacts. Instead of replacing momentjs constructs with dayjs everywhere, I decided to abstract it behind an adapter. This allowed me to keep the rest of my codebase library-agnostic while having the flexibility to swap in another library if necessary—without fear of introducing bugs or needing to retest every component.
Let’s break down the key parts of this implementation:
Private Constructor:
I made the constructor private to enforce the abstraction. This way, only the DateTimeAdapter class manages the dayjs instance internally, ensuring no external code relies on dayjs directly.Returning New Instances:
Methods like add() return a new DateTimeAdapter instance. This ensures immutability, which is crucial when working with dates. Mutating the current instance would lead to unexpected behavior in the rest of the system.Immutability
Returning New Instances: When operations like add, subtract, set, or clone are performed, you return a new instance of DateTimeAdapter instead of modifying the existing one. This practice ensures immutability, which is vital for entities like dates, where accidental mutations could lead to unexpected behavior across the application.
One of the key principles here is “writing against a contract.” In this case, the contract is the DateTimeAdapter class, which defines the interface my application code interacts with. The external library (dayjs, in this example) becomes a detail that can be swapped or replaced. This practice of coding to an interface, not an implementation, keeps the system flexible and easier to maintain.
// Abstract an external library for dealing with dates, datetime.
import dayjs, { OpUnitType, QUnitType, UnitType } from 'dayjs';
import timezone from 'dayjs/plugin/timezone';
import weekday from 'dayjs/plugin/weekday';
import utc from 'dayjs/plugin/utc';
import { isNil } from '@utils';
import { LocalDateTimeString, LocalTimeString } from '../utils/local_date_time';
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(weekday);
export enum Timezone {
UTC = 'UTC',
PST = 'America/Los_Angeles',
CST = 'America/Chicago',
EST = 'America/New_York',
}
// Mimics the types accepted by Dayjs, but purposely removes Dayjs as one of the accepted types.
export type DateTimeAdapterTypes = string | number | Date | null | undefined;
export class DateTimeAdapter {
// Holds the library core instance, this can be swapped with another library like moment
private instance: dayjs.Dayjs;
// Purposely made private for internal use, we cannot expose this outside since that
// leaks the library specific type(dayjs.Dayjs) to the caller. This will break abstraction.
private constructor(dayjs: dayjs.Dayjs) {
this.instance = dayjs;
}
static create(value: DateTimeAdapterTypes = undefined): DateTimeAdapter {
if (isNil(value)) {
return new DateTimeAdapter(dayjs());
}
return new DateTimeAdapter(dayjs(value));
}
format(template?: string): string {
return this.instance.format(template);
}
isBefore(value?: DateTimeAdapterTypes | DateTimeAdapter): boolean {
if (value instanceof DateTimeAdapter) {
return this.instance.isBefore(value.toDate());
} else {
return this.instance.isBefore(value);
}
}
isAfter(value?: DateTimeAdapterTypes | DateTimeAdapter): boolean {
if (value instanceof DateTimeAdapter) {
return this.instance.isAfter(value.toDate());
} else {
return this.instance.isAfter(value);
}
}
// https://day.js.org/docs/en/display/difference
diff(value: DateTimeAdapterTypes | DateTimeAdapter, unit?: string): number {
if (value instanceof DateTimeAdapter) {
return this.instance.diff(value.toDate(), <QUnitType | OpUnitType>unit);
} else {
return this.instance.diff(value, <QUnitType | OpUnitType>unit);
}
}
// Important to not mutate the current instance, modifications should always return new instance
add(value: number, unit: string): DateTimeAdapter {
// returns a clone i.e. a new dayJs, so the current instance should not be updated.
return new DateTimeAdapter(this.instance.add(value, unit));
}
toDate(): Date {
return this.instance.toDate();
}
valueOf(): number {
return this.instance.valueOf();
}
isValid(): boolean {
return this.instance.isValid();
}
subtract(number: number, unit: string): DateTimeAdapter {
return new DateTimeAdapter(this.instance.subtract(number, unit));
}
set(hour: string, number: number): DateTimeAdapter {
return DateTimeAdapter.create(
this.instance.set(<UnitType>hour, number).toDate(),
);
}
date(): number {
return this.instance.date();
}
clone(): DateTimeAdapter {
return new DateTimeAdapter(this.instance.clone());
}
isSame(value: DateTimeAdapter): boolean {
return this.instance.isSame(value.toDate());
}
toLocalTimeString(): LocalTimeString {
return this.instance.format('HH:mm');
}
setTimezone(zone: Timezone): DateTimeAdapter {
return new DateTimeAdapter(this.instance.tz(zone));
}
weekday(): number {
return this.instance.weekday();
}
hour(): number {
return this.instance.hour();
}
}
Looking back, implementing the Adapter Pattern has saved me a lot of headaches. It’s allowed me to keep my codebase flexible and protected from the quirks of external libraries. Whether it’s changing libraries or just needing to adapt to new project requirements, this pattern ensures I’m not locked into one solution. Going forward, I plan to apply this in other parts of my code where external dependencies exist—it’s a small investment that pays off big.
Top comments (0)