Introduction to Facades
Facades are a design pattern that provide a simplified interface to complex subsystems. Think of them as friendly front-desks that hide messy back-offices. Instead of dealing with the nitty-gritty details of a system's implementation, you get a clean, easy-to-use interface that handles the complexity for you.
The concept comes from architecture (literally the "face" of a building), but in software, facades serve as an abstraction layer that:
- Hides complexity
- Reduces dependencies between code
- Makes your system more modular
- Improves readability
- Provides a consistent interface
Why Facades Become Essential as Projects Grow
When starting a new project, it's tempting to skip facades. The codebase is small, you understand every part of it, and adding abstractions might feel like unnecessary overhead. But as your project grows, facades become lifesavers.
Here's why facades are worth implementing early:
Future-proofing: As your system grows, underlying implementations will change. With facades, these changes are isolated.
Maintainability: New team members can understand how to use complex subsystems without diving into implementation details.
Testing: Facades make it easier to mock dependencies and test in isolation.
Migration path: Need to swap out a library or service? Change the facade's implementation, not all the code that uses it.
Consistency: Enforces a standard way to interact with a subsystem across your application.
Often, developers only realize they need facades when technical debt piles up and changing anything becomes horrible. Starting with facades might seem like extra work, but it's an investment that pays off exponentially when your codebase expands.
Some facades I use in all of my projects
Let's look at some facades I've either built myself or copied from some youtube videos or blogs:
1. Error Handling Facade
I love this one, this is not mine i copied it from a famour youtuber theo, you might know him from his T3 stack.
Error handling is repetitive and clutters business logic. This facade provides a clean way to handle async operations and their potential errors:
// Types for the result object with discriminated union
type Success<T> = {
data: T;
error: null;
};
type Failure<E> = {
data: null;
error: E;
};
type Result<T, E = Error> = Success<T> | Failure<E>;
interface TryCatchOptions<T> {
onSuccess?: (data?: T) => void;
onError?: (error: unknown) => void;
}
// Main wrapper function
export async function tryCatch<T, E = Error>(
promise: Promise<T>,
options: TryCatchOptions<T> = {}
): Promise<Result<T, E>> {
try {
const data = await promise;
if (options.onSuccess) {
options.onSuccess(data);
}
return { data, error: null };
} catch (error) {
if (options.onError) {
options.onError(error);
}
return { data: null, error: error as E };
}
}
Using this facade turns messy try-catch blocks into clean, consistent error handling:
// Before
async function fetchUser(id) {
try {
const user = await api.getUser(id);
return user;
} catch (error) {
console.error("Failed to fetch user:", error);
return null;
}
}
// After
async function fetchUser(id) {
const { data, error } = await tryCatch(api.getUser(id), {
onError: (e) => console.error("Failed to fetch user:", e)
});
return data;
}
2. HTTP Client Facade
HTTP clients often need consistent error handling, authentication, and request formatting. This facade abstracts all that complexity:
import { clientEnv } from "@/clientEnv"
import axios from "axios"
import { getSession, signOut } from "next-auth/react"
const axiosClient = axios.create({
baseURL: clientEnv.NEXT_PUBLIC_API_ROOT_URL,
timeout: 30000,
headers: {
"Content-Type": "application/json",
},
})
axiosClient.interceptors.request.use(async (config) => {
const session = await getSession()
let jwtToken = session?.user?.jwtToken
if (jwtToken) {
// Verify token expiration
const payloadBase64 = jwtToken.split(".")[1]
const decodedPayload = JSON.parse(atob(payloadBase64))
const exp = decodedPayload?.exp * 1000
if (Date.now() >= exp) {
await signOut()
return Promise.reject("Session expired")
}
config.headers = {
...config.headers,
authorization: jwtToken,
}
}
return config
})
axiosClient.interceptors.response.use(
(response) => {
if (response && response.data) {
return response.data
}
return response
},
(error) => {
return Promise.reject(error)
}
)
export default class HttpClient {
static async get<T>(url: string, params?: unknown) {
const response = await axiosClient.get<T>(url, { params })
return response
}
static async post<T>(url: string, data: unknown, options?: any) {
const response = await axiosClient.post<T>(url, data, options)
return response
}
static async put<T>(url: string, data: unknown) {
const response = await axiosClient.put<T>(url, data)
return response
}
static async patch<T>(url: string, data: unknown) {
const response = await axiosClient.patch<T>(url, data)
return response
}
static async delete<T>(url: string) {
const response = await axiosClient.delete<T>(url)
return response
}
}
Using this facade turns complex API calls with authentication, error handling, and data extraction into simple, clean one-liners.
// Before
import axios from "axios";
// Or whatever you use
import { getSession } from "next-auth/react";
async function fetchUsers() {
try {
const session = await getSession();
const response = await axios.get("https://api.example.com/users", {
headers: {
"Content-Type": "application/json",
authorization: session?.user?.jwtToken
}
});
return response.data;
} catch (error) {
console.error("Failed to fetch users:", error);
return [];
}
}
// After
import HttpClient from "@/lib/HttpClient";
async function fetchUsers() {
try {
const users = await HttpClient.get("/users");
return users;
} catch (error) {
console.error("Failed to fetch users:", error);
return [];
}
}
3. Storage Facade
Here's a storage facade I built that abstracts away different storage mechanisms:
interface StorageProvider {
get<T>(key: string): T | null;
set<T>(key: string, value: T): void;
remove(key: string): void;
clear(): void;
}
class LocalStorageProvider implements StorageProvider {
get<T>(key: string): T | null {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : null;
} catch {
return null;
}
}
set<T>(key: string, value: T): void {
localStorage.setItem(key, JSON.stringify(value));
}
remove(key: string): void {
localStorage.removeItem(key);
}
clear(): void {
localStorage.clear();
}
}
class MemoryStorageProvider implements StorageProvider {
private store: Record<string, string> = {};
get<T>(key: string): T | null {
try {
const item = this.store[key];
return item ? JSON.parse(item) : null;
} catch {
return null;
}
}
set<T>(key: string, value: T): void {
this.store[key] = JSON.stringify(value);
}
remove(key: string): void {
delete this.store[key];
}
clear(): void {
this.store = {};
}
}
export class Storage {
private provider: StorageProvider;
constructor(providerType: 'local' | 'memory' = 'local') {
this.provider = providerType === 'local'
? new LocalStorageProvider()
: new MemoryStorageProvider();
}
get<T>(key: string): T | null {
return this.provider.get<T>(key);
}
set<T>(key: string, value: T): void {
this.provider.set<T>(key, value);
}
remove(key: string): void {
this.provider.remove(key);
}
clear(): void {
this.provider.clear();
}
}
Using this facade turns complex storage interactions with different storage providers, type safety, and error handling into a simple, consistent API that works across environments.
// Before
function saveUserPreferences(theme: string, fontSize: number, notifications: boolean) {
try {
localStorage.setItem('userTheme', theme);
localStorage.setItem('userFontSize', fontSize.toString());
localStorage.setItem('userNotifications', JSON.stringify(notifications));
} catch (e) {
console.error('Failed to save preferences:', e);
}
}
function getUserPreferences() {
try {
const theme = localStorage.getItem('userTheme') || 'light';
const fontSize = Number(localStorage.getItem('userFontSize')) || 16;
const notifications = localStorage.getItem('userNotifications') === 'true';
return { theme, fontSize, notifications };
} catch (e) {
console.error('Failed to get preferences:', e);
return { theme: 'light', fontSize: 16, notifications: true };
}
}
// After
import { Storage } from '@/lib/Storage';
const storage = new Storage('local');
function saveUserPreferences(theme: string, fontSize: number, notifications: boolean) {
storage.set('userPreferences', { theme, fontSize, notifications });
}
function getUserPreferences() {
return storage.get<{ theme: string, fontSize: number, notifications: boolean }>('userPreferences') ||
{ theme: 'light', fontSize: 16, notifications: true };
}
4. Logger Facade
Another useful facade is a centralized logging system:
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
interface LoggerOptions {
minLevel?: LogLevel;
enableConsole?: boolean;
enableRemote?: boolean;
appVersion?: string;
}
export class Logger {
private static instance: Logger;
private minLevel: LogLevel;
private enableConsole: boolean;
private enableRemote: boolean;
private appVersion: string;
private readonly levelPriority: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3
};
private constructor(options: LoggerOptions = {}) {
this.minLevel = options.minLevel || 'info';
this.enableConsole = options.enableConsole ?? true;
this.enableRemote = options.enableRemote ?? false;
this.appVersion = options.appVersion || 'unknown';
}
static getInstance(options?: LoggerOptions): Logger {
if (!Logger.instance) {
Logger.instance = new Logger(options);
}
return Logger.instance;
}
private shouldLog(level: LogLevel): boolean {
return this.levelPriority[level] >= this.levelPriority[this.minLevel];
}
private async sendToRemote(level: LogLevel, message: string, data?: any) {
if (!this.enableRemote) return;
try {
await fetch('/api/logs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
level,
message,
data,
timestamp: new Date().toISOString(),
appVersion: this.appVersion
})
});
} catch (e) {
// Fail silently to prevent logging loops
}
}
debug(message: string, data?: any): void {
if (!this.shouldLog('debug')) return;
if (this.enableConsole) {
console.debug(`[DEBUG] ${message}`, data);
}
this.sendToRemote('debug', message, data);
}
info(message: string, data?: any): void {
if (!this.shouldLog('info')) return;
if (this.enableConsole) {
console.info(`[INFO] ${message}`, data);
}
this.sendToRemote('info', message, data);
}
warn(message: string, data?: any): void {
if (!this.shouldLog('warn')) return;
if (this.enableConsole) {
console.warn(`[WARN] ${message}`, data);
}
this.sendToRemote('warn', message, data);
}
error(message: string, error?: any): void {
if (!this.shouldLog('error')) return;
if (this.enableConsole) {
console.error(`[ERROR] ${message}`, error);
}
this.sendToRemote('error', message, error);
}
}
Using this facade turns inconsistent logging across your application with different formats and destinations into a centralized, configurable system with consistent formatting and remote reporting capabilities.
// Before
function processOrder(order) {
console.log("Processing order:", order.id);
try {
// Order processing logic
if (!order.paymentConfirmed) {
console.warn("Payment not confirmed for order:", order.id);
return false;
}
// Some More processing...
console.log("Order processed successfully:", order.id);
return true;
} catch (error) {
console.error("Failed to process order:", order.id, error);
return false;
}
}
// After
import { Logger } from '@/lib/Logger';
const logger = Logger.getInstance({
minLevel: 'info',
enableRemote: true,
appVersion: '1.2.3'
});
function processOrder(order) {
logger.info("Processing order", { orderId: order.id });
try {
// Order processing logic
if (!order.paymentConfirmed) {
logger.warn("Payment not confirmed", { orderId: order.id });
return false;
}
// SOme More processing...
logger.info("Order processed successfully", { orderId: order.id });
return true;
} catch (error) {
logger.error("Failed to process order", { orderId: order.id, error });
return false;
}
}
The Facade Mindset
Beyond specific implementations, adopting a "facade mindset" is about identifying which parts of your system benefit from abstraction. When you notice repetitive pattern or complexity that others struggle with, that's your hint to build a facade.
Good facade opportunities include:
- Third-party services integration
- Database operations
- Authentication/Authorization systems
- File operations
- Complex algorithms
Conclusion
Facades aren't just fancy design patterns they're practical tools that save time, reduce bugs, and make codebases more maintainable. While they might seem like unnecessary abstraction at first, facades become invaluable as your project evolves.
The examples I've shared are just starting points. The best facades solve specific problems in your domain, creating a language that speaks directly to your business needs while hiding technical complexity.
Next time you write repeated boilerplate or explain a complex subsystem to a teammate for the third time, consider creating a facade. Your future self (and team) will thank you.
Connect with Me
If you enjoyed this post and want to stay in the loop with similar content, feel free to follow and connect with me across the web:
- Twitter: Follow me on Twitter for bite-sized tips, updates, and thoughts on tech.
- Medium: Check out my articles on Medium where I share tutorials, insights, and deep dives.
- Email: Got questions, ideas, or just want to say hi? Drop me a line at codezera3@gmail.com.
Your support means a lot, and I'd love to connect, collaborate, or just geek out over cool projects. Looking forward to hearing from you!
Top comments (0)