Will My Frontend App Age Like Fine Wine - or Like Internet Explorer


JavaScript: The Accidental Empire 🏛️
Front-end development has surely become an empire ruling the web, and JavaScript became one of the most used programming languages in the world. But there is one problem—the JavaScript language was not designed for what it is going through these days.
Some of you may already know that the language itself was designed back in the '90s 🕰️, and the main goal was to update the content of HTML pages. A little over a decade later, it was skyrocketed by Google's browser revolution—the V8 engine (not the motor one 🚗💨)—which sent the browser's interpreter way up into the sky.
And suddenly, from modifying simple pages, the entire Web came to rely on JavaScript. The summary? A tool built for casual web interactivity found itself running global-scale applications. This story goes on and includes Just-in-Time compilers, the development of the internet, smart devices, TypeScript (a subset language just for adding types), and much more—but that's a whole new topic for another article.
⏩ Fast-Forward to the Last Decade
We've seen entire frameworks rise and fall. Who would've thought that:
jQuery would no longer be the web standard?
Backbone and Ember would no longer be the cool kids in the front-end neighborhood?
The evolution of React, Angular, and Vue was (and still is) a huge topic—they came not to take part, but to take over (at least for now). Each promised more efficiency, better patterns, and cleaner code. Alongside them came:
State management
Module bundlers
CSS-in-JS trends (shifting almost every year 📅)
And countless other tools, patterns, and environments
This isn't even the top of the iceberg yet. 🧊
Nowadays, when going through a front-end app, it's very likely to confuse application logic with some development tool configuration.
Can you tell what this code does:
// Is this configuration or business logic?
const settings = {
mode: process.env.REACT_APP_MODE || 'default',
features: {
analytics: true,
caching: process.env.NODE_ENV === 'production',
experimental: false
},
endpoints: {
api: window.location.hostname === 'localhost'
? 'http://localhost:3001/api'
: '/api',
auth: '/auth'
}
};
function initializeApp() {
if (settings.mode === 'legacy') {
configureLegacySystem();
} else if (settings.features.experimental) {
enableExperimentalFeatures();
}
if (settings.features.analytics) {
initAnalytics(settings.endpoints.api);
}
}
The provided code snippet can be for app configuration or runtime business logic.
The front-end tech stack has become a big pile of tools. For developers, all of this is a bit hard pill to swallow—yet we're still happy to write our apps.
Until...
One beautiful day the UI framework becomes unsupported or the organization wants to migrate to another tech stack, to another framework…
Then, too often, the answer is a costly rewrite - the harsh truth - not because the business rules require it. If we dig further, not even the frameworks or technologies are to blame, but because—let’s face it—the tech world is hyper-dynamic, and "breaking changes" are a common keyword in docs for nearly every software update.
The real culprit?
Software architecture and design decisions that failed to handle change.
🦖 My Framework Ate My Architecture
The Framework-Centric Dilemma
A lot of Single Page Applications (SPAs) nowadays are designed around frameworks. Each framework offers its own architectural guidance, such as:
Component trees
Context systems
Hooks and effects
Reactive stores
Computed values
These are valuable tools — but they're not architectural foundations. They are implementations, not principles.
If we build applications within the patterns of a specific framework, we inherit its strengths, but also its lifespan, its support cycle, and its community.
To build Single Page Applications that endure, we must reverse this dependency:
Frameworks should serve the architecture — not define it.
As a result, apps become very hard and often impossible to migrate without complete rewrites. Frequently, the code itself might be good, but the software's fragility stems from architectural and design decisions made by framework conventions rather than deliberate planning.
When Libraries Design Your App
Frameworks like React, Angular, and Vue have brought enormous productivity gains. They offer powerful abstractions for:
UI management
State handling
Interactivity
Without them, front-end development wouldn't be what it is today. But while frameworks are great tools, they shouldn't define application structure.
Analogy: They're like power tools — fantastic when used properly, but dangerous when you build your entire house around just one brand of drill.
The FLUX Example
When developing a React app with Redux, the app's architecture typically becomes FLUX. This raises important questions:
Did you consciously choose FLUX?
Or did the architecture emerge by accident when selecting React+Redux?
If your app eventually fails due to architectural limitations (which isn't so uncommon), you can't say:
"Sorry, not my fault — Redux made me do it."
The truth is: This is your app, your development choices, and ultimately your architecture.
The Case for Technology-Agnostic Architecture
Technology-agnostic architecture provides a solution. While the term is broad (and arguably all architecture should be technology-agnostic), it essentially means:
Structuring applications (front-end apps in our case) so that:
Core logic
Domain models
Workflows
Remain stable, while frameworks and tools become replaceable layers.
♾️🧩 I Have 99 Design Patterns, But The Framework Ain’t One
Instead of anchoring our application architecture to a specific library or framework, we should design around fundamental principles that remain valid regardless of our tools. These principles ensure our codebase is:
Flexible
Testable
Easier to evolve
...even when our UI framework inevitably changes.
Let's examine some fundamental patterns with framework-agnostic examples:
👉 Separation of Concerns
Each part of our app should have one job — no more, no less. The user interface, the business logic, the domain specific operations and many others, should exist in well-defined, isolated layers or modules. When concerns are properly separated, each part can have its own evolution, independently from the others. This is essential for scaling and maintaining complex applications.
Example: Avoid writing API calls directly inside our React component’s useEffect
. Instead, move them into a service or use case module.
Bad Practice:
// UserProfile.js - Poor separation of concerns
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
const fetchUser = async () => {
setLoading(true);
try {
// API call directly in component
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
setUser(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchUser();
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div>
<h1>{user?.name}</h1>
<p>Email: {user?.email}</p>
</div>
);
}
Best Practice:
// services/userService.js - API service layer
export async function getUser(userId) {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error('Failed to fetch user');
}
return response.json();
}
// UserProfile.js - Clean component
import { useState, useEffect } from 'react';
import { getUser } from './services/userService';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
const loadUser = async () => {
setLoading(true);
try {
// Using the service layer
const userData = await getUser(userId);
setUser(userData);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
loadUser();
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div>
<h1>{user?.name}</h1>
<p>Email: {user?.email}</p>
</div>
);
}
This example goes for every aspect of your app, not only for API calls. In this way if tomorrow the UI layer framework is to be changed/updated, the logic of the app will stay intact.
👉 Inversion of Control
The control should be inverted towards the high level policies and away from low-level policies. Rather than letting frameworks dictate the application's structure, the core logic should define what needs to happen — and delegate how it happens to interchangeable components. This reversal of dependency direction helps ensure that business logic remains insulated from technology choices.
Example: Pass a data-fetching function into a use case instead of hardcoding fetch()
or axios
calls inside it.
Bad Practice:
// getUserProfile.ts
export async function getUserProfile(userId: string) {
const response = await fetch(`https://api.example.com/users/${userId}`);
const data = await response.json();
return { id: data.id, name: data.name };
}
Best Practice:
// types.ts
export type HttpClient = (url: string, options?: RequestInit) => Promise<any>;
// getUserProfile.ts
import { HttpClient } from './types';
export function makeGetUserProfile(httpClient: HttpClient) {
return async function getUserProfile(userId: string) {
const data = await httpClient(`https://api.example.com/users/${userId}`);
return { id: data.id, name: data.name };
};
}
// http/fetchHttpClient.ts
import { HttpClient } from '../types';
export const fetchHttpClient: HttpClient = async (url, options) => {
const response = await fetch(url, options);
if (!response.ok) throw new Error('Request failed');
return await response.json();
};
// index.ts
import { makeGetUserProfile } from './getUserProfile';
import { fetchHttpClient } from './http/fetchHttpClient';
const getUserProfile = makeGetUserProfile(fetchHttpClient);
getUserProfile('123').then(console.log);
Interface-Driven Design
The abstract contracts, which are describing behaviors and structures are a key in creating high quality software. This can be achieved through interfaces. By using abstract interfaces, instead of concrete implementation details, the system remains agnostic to underlying technologies. Hence, enabling interchangeable software components.
Example: Define a UserRepository
interface that your React app or any other client can implement — not a concrete UserServiceAxios
.
Abstract contracts — such as interfaces or protocols — define the expected behaviors and structures without committing to specific implementations.
Designing around interfaces instead of concrete classes allows the system to remain agnostic to underlying technologies. This abstraction enables interchangeable components, improved testability, and cleaner dependency boundaries.
Bad Practice:
import axios, { AxiosInstance } from 'axios';
// Bad: Client depends directly on concrete implementation
class UserServiceAxios {
private http: AxiosInstance;
constructor() {
this.http = axios.create({ baseURL: '/api' });
}
async getUser(id: string) {
const response = await this.http.get(`/users/${id}`);
return response.data;
}
async createUser(userData: { name: string; email: string }) {
const response = await this.http.post('/users', userData);
return response.data;
}
async updateUser(id: string, updates: Partial<{ name: string; email: string }>) {
const response = await this.http.patch(`/users/${id}`, updates);
return response.data;
}
async deleteUser(id: string) {
await this.http.delete(`/users/${id}`);
}
}
// Client is tightly coupled to UserServiceAxios
class UserProfile {
private service: UserServiceAxios;
constructor() {
this.service = new UserServiceAxios(); // Can't easily substitute implementation
}
async showUser(id: string) {
const user = await this.service.getUser(id);
console.log('User:', user);
}
}
Best Practice
interface User {
id: string;
name: string;
email: string;
}
// 1. First define the interface (contract)
interface UserRepository {
getUser(id: string): Promise<User>;
createUser(user: Omit<User, 'id'>): Promise<User>;
updateUser(id: string, updates: Partial<Omit<User, 'id'>>): Promise<User>;
deleteUser(id: string): Promise<void>;
}
// 2. Implement the interface with Axios
class AxiosUserRepository implements UserRepository {
private http: AxiosInstance;
constructor(http: AxiosInstance) {
this.http = http;
}
async getUser(id: string): Promise<User> {
const response = await this.http.get<User>(`/users/${id}`);
return response.data;
}
async createUser(user: Omit<User, 'id'>): Promise<User> {
const response = await this.http.post<User>('/users', user);
return response.data;
}
async updateUser(id: string, updates: Partial<Omit<User, 'id'>>): Promise<User> {
const response = await this.http.patch<User>(`/users/${id}`, updates);
return response.data;
}
async deleteUser(id: string): Promise<void> {
await this.http.delete(`/users/${id}`);
}
}
// 3. Alternative implementation for testing
class MockUserRepository implements UserRepository {
private users: User[] = [];
async getUser(id: string): Promise<User> {
const user = this.users.find(u => u.id === id);
if (!user) throw new Error('User not found');
return user;
}
async createUser(user: Omit<User, 'id'>): Promise<User> {
const newUser = { ...user, id: Math.random().toString() };
this.users.push(newUser);
return newUser;
}
async updateUser(id: string, updates: Partial<Omit<User, 'id'>>): Promise<User> {
const index = this.users.findIndex(u => u.id === id);
if (index === -1) throw new Error('User not found');
this.users[index] = { ...this.users[index], ...updates };
return this.users[index];
}
async deleteUser(id: string): Promise<void> {
this.users = this.users.filter(u => u.id !== id);
}
}
// 4. Client depends only on the interface
class UserProfile {
constructor(private repository: UserRepository) {}
async showUser(id: string) {
const user = await this.repository.getUser(id);
console.log('User:', user);
}
}
// Usage examples:
const productionRepo = new AxiosUserRepository(axios.create({ baseURL: '/api' }));
const productionProfile = new UserProfile(productionRepo);
const testRepo = new MockUserRepository();
const testProfile = new UserProfile(testRepo);
👉 Loose Coupling
Modules must minimize their knowledge of one another. This principle guarantees that changes in one part of the modules require minimal or in the best case no change elsewhere. This approach enhances the overall scalability and testability of the app.
Example: Your form validation logic should live outside your UI components — that way, you can reuse it across web and mobile apps.
Bad Practice:
// Tightly coupled form validation inside UI component
class LoginForm {
private email: string = '';
private password: string = '';
validateEmail(email: string): boolean {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(email);
}
validatePassword(password: string): boolean {
return password.length >= 8;
}
handleSubmit() {
if (!this.validateEmail(this.email)) {
console.error('Invalid email format');
return;
}
if (!this.validatePassword(this.password)) {
console.error('Password must be at least 8 characters');
return;
}
// Submit logic...
console.log('Submitting:', { email: this.email, password: this.password });
}
setEmail(email: string) {
this.email = email;
}
setPassword(password: string) {
this.password = password;
}
}
// Usage - validation logic can't be reused
const form = new LoginForm();
form.setEmail('test@example.com');
form.setPassword('12345678');
form.handleSubmit();
Best Practice
// 1. Define validation interfaces (contracts)
interface Validator<T> {
validate(value: T): { isValid: boolean; error?: string };
}
interface LoginCredentials {
email: string;
password: string;
}
// 2. Implement standalone validation logic
class EmailValidator implements Validator<string> {
validate(email: string) {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return {
isValid: regex.test(email),
error: regex.test(email) ? undefined : 'Invalid email format'
};
}
}
class PasswordValidator implements Validator<string> {
validate(password: string) {
const isValid = password.length >= 8;
return {
isValid,
error: isValid ? undefined : 'Password must be at least 8 characters'
};
}
}
// 3. Create validation service that can be used anywhere
class LoginValidator implements Validator<LoginCredentials> {
private emailValidator = new EmailValidator();
private passwordValidator = new PasswordValidator();
validate(credentials: LoginCredentials) {
const emailResult = this.emailValidator.validate(credentials.email);
const passwordResult = this.passwordValidator.validate(credentials.password);
return {
isValid: emailResult.isValid && passwordResult.isValid,
errors: {
email: emailResult.error,
password: passwordResult.error
}
};
}
}
// 4. UI Component depends only on validator interface
class LoginForm {
private credentials: LoginCredentials = { email: '', password: '' };
private validator: Validator<LoginCredentials>;
constructor(validator: Validator<LoginCredentials>) {
this.validator = validator;
}
handleSubmit() {
const result = this.validator.validate(this.credentials);
if (!result.isValid) {
console.error('Validation errors:', result.errors);
return;
}
// Submit logic...
console.log('Submitting:', this.credentials);
}
setEmail(email: string) {
this.credentials.email = email;
}
setPassword(password: string) {
this.credentials.password = password;
}
}
// Usage examples:
// Web app usage
const webValidator = new LoginValidator();
const webForm = new LoginForm(webValidator);
// Mobile app could use the same validator
const mobileValidator = new LoginValidator();
// const mobileForm = new MobileLoginForm(mobileValidator);
// Testing with mock validator
const mockValidator: Validator<LoginCredentials> = {
validate: () => ({ isValid: true })
};
const testForm = new LoginForm(mockValidator);
📈📉🏛️ Frameworks raise and fall but principles stand
Software development is a relatively new field — only a few decades old. Yet over this short period, we’ve witnessed the rapid rise and fall of programming languages, frameworks, libraries, and countless tools.
Despite this constant change, core software principles remain steady and reliable, such as:
Separation of concerns
Directional dependencies
Inversion of control
Loose coupling
These principles offer something far more enduring than temporary trends: resilience.
No matter what library you're using — whether it's for UI presentation, HTTP data fetching, or displaying a map — you shouldn't build your entire app around it.
The only way to ensure your application outlives the tools you choose is to architect it around principles, not products. This approach allows your codebase to mature gracefully — like a fine wine — rather than follow the faith of Internet Explorer.
🔑 Key Takeaways
Frameworks are tools, not foundations.
Tech agnosticism isn't about being anti-framework — it's about being future-ready.
If migrating frameworks means rewriting everything, the problem isn’t the framework — it’s your architecture.
Subscribe to my newsletter
Read articles from Zlatoslav Marchev directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
