On this page
MVC (Model-View-Controller) Pattern
Date: 2025-22-09
Status: accepted
Current Architecture Analysis
The Vega web application is built with Remix (React Router v7) and follows a feature-based routing structure with the following characteristics:
Current Structure
services/remix/
├── app/
│ ├── components/ # Shared components
│ │ ├── ui/ # Design system components (shadcn/ui based)
│ │ ├── chat/ # Chat-related components
│ │ ├── forms/ # Form components
│ │ └── shared/ # Shared business components
│ ├── routes/ # Route-based organization
│ │ ├── alerts+/ # Alerts feature
│ │ ├── dashboards+/ # Dashboards feature
│ │ ├── detections+/ # Detections feature
│ │ ├── assessment+/ # Data assessment feature
│ │ ├── intel+/ # Intelligence feature
│ │ ├── notebooks+/ # Notebooks feature
│ │ ├── query+/ # Query feature
│ │ ├── settings+/ # Settings feature
│ │ └── _auth+/ # Authentication
│ ├── queries/ # GraphQL queries
│ ├── utils/ # Utility functions
│ └── transformations/ # Data transformations
Key Technologies
- Framework: Remix (React Router v7) with Vite
- UI Library: shadcn/ui components + Radix UI primitives
- State Management: Zustand + React Query
- Styling: Tailwind CSS
- Data Fetching: GraphQL with custom hooks
- Testing: Vitest + Playwright
MVC Pattern Migration
What is MVC?
Model-View-Controller (MVC) is an architectural pattern that separates an application into three interconnected components:
- Model: Manages data and business logic
- View: Handles user interface and presentation
- Controller: Manages user input and coordinates between Model and View
MVC Structure for React/Remix
src/
├── models/ # Data and business logic
│ ├── User.ts # User model
│ ├── Detection.ts # Detection model
│ ├── Dashboard.ts # Dashboard model
│ └── index.ts # Model exports
├── views/ # UI components (View layer)
│ ├── components/ # Reusable UI components
│ ├── pages/ # Page components
│ └── layouts/ # Layout components
├── controllers/ # Business logic and state management
│ ├── UserController.ts # User business logic
│ ├── DetectionController.ts
│ ├── DashboardController.ts
│ └── index.ts
├── services/ # External services and API calls
│ ├── api/ # API services
│ ├── auth/ # Authentication services
│ └── index.ts
└── utils/ # Utility functions
Migration Strategy
Phase 1: Extract Models
Step 1: User Model
// src/models/User.ts
export interface User {
id: string;
email: string;
name: string;
role: string;
permissions: string[];
avatar?: string;
createdAt: string;
updatedAt: string;
}
export class UserModel {
private static instance: UserModel;
private user: User | null = null;
private listeners: Array<(user: User | null) => void> = [];
static getInstance(): UserModel {
if (!UserModel.instance) {
UserModel.instance = new UserModel();
}
return UserModel.instance;
}
setUser(user: User | null): void {
this.user = user;
this.notifyListeners();
}
getUser(): User | null {
return this.user;
}
isAuthenticated(): boolean {
return this.user !== null;
}
hasPermission(permission: string): boolean {
return this.user?.permissions.includes(permission) ?? false;
}
subscribe(listener: (user: User | null) => void): () => void {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter(l => l !== listener);
};
}
private notifyListeners(): void {
this.listeners.forEach(listener => listener(this.user));
}
}Step 2: Detection Model
// src/models/Detection.ts
export interface Detection {
id: string;
name: string;
description: string;
query: string;
severity: 'low' | 'medium' | 'high' | 'critical';
status: 'active' | 'inactive' | 'draft';
mitreTechniques: string[];
createdAt: string;
updatedAt: string;
createdBy: string;
}
export class DetectionModel {
private static instance: DetectionModel;
private detections: Detection[] = [];
private currentDetection: Detection | null = null;
private listeners: Array<(detections: Detection[]) => void> = [];
static getInstance(): DetectionModel {
if (!DetectionModel.instance) {
DetectionModel.instance = new DetectionModel();
}
return DetectionModel.instance;
}
setDetections(detections: Detection[]): void {
this.detections = detections;
this.notifyListeners();
}
getDetections(): Detection[] {
return this.detections;
}
getDetectionById(id: string): Detection | undefined {
return this.detections.find(d => d.id === id);
}
setCurrentDetection(detection: Detection | null): void {
this.currentDetection = detection;
}
getCurrentDetection(): Detection | null {
return this.currentDetection;
}
addDetection(detection: Detection): void {
this.detections.push(detection);
this.notifyListeners();
}
updateDetection(id: string, updates: Partial<Detection>): void {
const index = this.detections.findIndex(d => d.id === id);
if (index !== -1) {
this.detections[index] = { ...this.detections[index], ...updates };
this.notifyListeners();
}
}
deleteDetection(id: string): void {
this.detections = this.detections.filter(d => d.id !== id);
this.notifyListeners();
}
subscribe(listener: (detections: Detection[]) => void): () => void {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter(l => l !== listener);
};
}
private notifyListeners(): void {
this.listeners.forEach(listener => listener(this.detections));
}
}Phase 2: Extract Controllers
Step 1: User Controller
// src/controllers/UserController.ts
import { UserModel, type User } from '../models/User';
import { authService } from '../services/authService';
import { userService } from '../services/userService';
export class UserController {
private userModel: UserModel;
constructor() {
this.userModel = UserModel.getInstance();
}
async login(credentials: { email: string; password: string }): Promise<void> {
try {
const { token, user } = await authService.login(credentials);
localStorage.setItem('token', token);
this.userModel.setUser(user);
} catch (error) {
throw new Error('Login failed');
}
}
async logout(): Promise<void> {
try {
await authService.logout();
localStorage.removeItem('token');
this.userModel.setUser(null);
} catch (error) {
console.error('Logout error:', error);
}
}
async getCurrentUser(): Promise<User | null> {
try {
const user = await userService.getCurrentUser();
this.userModel.setUser(user);
return user;
} catch (error) {
console.error('Get current user error:', error);
return null;
}
}
async updateProfile(updates: Partial<User>): Promise<void> {
try {
const updatedUser = await userService.updateProfile(updates);
this.userModel.setUser(updatedUser);
} catch (error) {
throw new Error('Profile update failed');
}
}
isAuthenticated(): boolean {
return this.userModel.isAuthenticated();
}
hasPermission(permission: string): boolean {
return this.userModel.hasPermission(permission);
}
subscribeToUserChanges(callback: (user: User | null) => void): () => void {
return this.userModel.subscribe(callback);
}
}Step 2: Detection Controller
// src/controllers/DetectionController.ts
import { DetectionModel, type Detection } from '../models/Detection';
import { detectionService } from '../services/detectionService';
export class DetectionController {
private detectionModel: DetectionModel;
constructor() {
this.detectionModel = DetectionModel.getInstance();
}
async loadDetections(): Promise<void> {
try {
const detections = await detectionService.getDetections();
this.detectionModel.setDetections(detections);
} catch (error) {
throw new Error('Failed to load detections');
}
}
async createDetection(detectionData: Omit<Detection, 'id' | 'createdAt' | 'updatedAt'>): Promise<Detection> {
try {
const newDetection = await detectionService.createDetection(detectionData);
this.detectionModel.addDetection(newDetection);
return newDetection;
} catch (error) {
throw new Error('Failed to create detection');
}
}
async updateDetection(id: string, updates: Partial<Detection>): Promise<void> {
try {
await detectionService.updateDetection(id, updates);
this.detectionModel.updateDetection(id, updates);
} catch (error) {
throw new Error('Failed to update detection');
}
}
async deleteDetection(id: string): Promise<void> {
try {
await detectionService.deleteDetection(id);
this.detectionModel.deleteDetection(id);
} catch (error) {
throw new Error('Failed to delete detection');
}
}
setCurrentDetection(detection: Detection | null): void {
this.detectionModel.setCurrentDetection(detection);
}
getCurrentDetection(): Detection | null {
return this.detectionModel.getCurrentDetection();
}
getDetections(): Detection[] {
return this.detectionModel.getDetections();
}
getDetectionById(id: string): Detection | undefined {
return this.detectionModel.getDetectionById(id);
}
subscribeToDetections(callback: (detections: Detection[]) => void): () => void {
return this.detectionModel.subscribe(callback);
}
}Phase 3: Extract Views
Step 1: User View Components
// src/views/components/UserProfile.tsx
import React, { useEffect, useState } from 'react';
import { userController } from '../controllers';
import type { User } from '../../models/User';
export function UserProfile() {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const unsubscribe = userController.subscribeToUserChanges(setUser);
const loadUser = async () => {
setLoading(true);
try {
await userController.getCurrentUser();
} finally {
setLoading(false);
}
};
loadUser();
return unsubscribe;
}, []);
if (loading) {
return <div>Loading...</div>;
}
if (!user) {
return <div>No user data</div>;
}
return (
<div className="user-profile">
<h2>{user.name}</h2>
<p>{user.email}</p>
<p>Role: {user.role}</p>
</div>
);
}
// src/views/components/LoginForm.tsx
import React, { useState } from 'react';
import { userController } from '../controllers';
export function LoginForm() {
const [credentials, setCredentials] = useState({ email: '', password: '' });
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
await userController.login(credentials);
} catch (err) {
setError('Login failed');
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={credentials.email}
onChange={(e) => setCredentials(prev => ({ ...prev, email: e.target.value }))}
placeholder="Email"
required
/>
<input
type="password"
value={credentials.password}
onChange={(e) => setCredentials(prev => ({ ...prev, password: e.target.value }))}
placeholder="Password"
required
/>
{error && <div className="error">{error}</div>}
<button type="submit" disabled={loading}>
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
);
}Step 2: Detection View Components
// src/views/components/DetectionList.tsx
import React, { useEffect, useState } from 'react';
import { detectionController } from '../controllers';
import type { Detection } from '../../models/Detection';
export function DetectionList() {
const [detections, setDetections] = useState<Detection[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const unsubscribe = detectionController.subscribeToDetections(setDetections);
const loadDetections = async () => {
setLoading(true);
try {
await detectionController.loadDetections();
} finally {
setLoading(false);
}
};
loadDetections();
return unsubscribe;
}, []);
const handleDelete = async (id: string) => {
try {
await detectionController.deleteDetection(id);
} catch (error) {
console.error('Delete failed:', error);
}
};
if (loading) {
return <div>Loading detections...</div>;
}
return (
<div className="detection-list">
<h2>Detections</h2>
{detections.map(detection => (
<div key={detection.id} className="detection-item">
<h3>{detection.name}</h3>
<p>{detection.description}</p>
<span className={`severity ${detection.severity}`}>
{detection.severity}
</span>
<button onClick={() => handleDelete(detection.id)}>
Delete
</button>
</div>
))}
</div>
);
}
// src/views/components/CreateDetectionForm.tsx
import React, { useState } from 'react';
import { detectionController } from '../controllers';
export function CreateDetectionForm() {
const [formData, setFormData] = useState({
name: '',
description: '',
query: '',
severity: 'medium' as const,
});
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
await detectionController.createDetection({
...formData,
status: 'draft',
mitreTechniques: [],
createdBy: 'current-user-id',
});
setFormData({ name: '', description: '', query: '', severity: 'medium' });
} catch (error) {
console.error('Create detection failed:', error);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="Detection Name"
required
/>
<textarea
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
placeholder="Description"
required
/>
<textarea
value={formData.query}
onChange={(e) => setFormData(prev => ({ ...prev, query: e.target.value }))}
placeholder="Query"
required
/>
<select
value={formData.severity}
onChange={(e) => setFormData(prev => ({ ...prev, severity: e.target.value as any }))}
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
<button type="submit" disabled={loading}>
{loading ? 'Creating...' : 'Create Detection'}
</button>
</form>
);
}Phase 4: Extract Services
Step 1: Authentication Service
// src/services/authService.ts
import { api } from './api';
import type { User } from '../models/User';
export interface LoginCredentials {
email: string;
password: string;
}
export interface LoginResponse {
token: string;
user: User;
}
export const authService = {
async login(credentials: LoginCredentials): Promise<LoginResponse> {
const response = await api.post('/auth/login', credentials);
return response.data;
},
async logout(): Promise<void> {
await api.post('/auth/logout');
},
async refreshToken(): Promise<string> {
const response = await api.post('/auth/refresh');
return response.data.token;
},
async validateToken(token: string): Promise<boolean> {
try {
await api.get('/auth/validate', {
headers: { Authorization: `Bearer ${token}` }
});
return true;
} catch {
return false;
}
}
};Step 2: Detection Service
// src/services/detectionService.ts
import { api } from './api';
import type { Detection } from '../models/Detection';
export const detectionService = {
async getDetections(): Promise<Detection[]> {
const response = await api.get('/detections');
return response.data;
},
async getDetectionById(id: string): Promise<Detection> {
const response = await api.get(`/detections/${id}`);
return response.data;
},
async createDetection(data: Omit<Detection, 'id' | 'createdAt' | 'updatedAt'>): Promise<Detection> {
const response = await api.post('/detections', data);
return response.data;
},
async updateDetection(id: string, updates: Partial<Detection>): Promise<Detection> {
const response = await api.put(`/detections/${id}`, updates);
return response.data;
},
async deleteDetection(id: string): Promise<void> {
await api.delete(`/detections/${id}`);
}
};Phase 5: Update Remix Routes
Step 1: Update Route Structure
// app/routes/detections+/_index.tsx
import { type LoaderFunctionArgs } from 'react-router';
import { DetectionList } from '@/views/components/DetectionList';
import { CreateDetectionForm } from '@/views/components/CreateDetectionForm';
import { detectionController } from '@/controllers';
export async function loader({ request, context }: LoaderFunctionArgs) {
// Load initial data
await detectionController.loadDetections();
return {};
}
export default function DetectionsPage() {
return (
<div>
<h1>Detections</h1>
<CreateDetectionForm />
<DetectionList />
</div>
);
}MVC Benefits
- Separation of Concerns: Clear separation between data, logic, and presentation
- Maintainability: Easy to modify individual components without affecting others
- Testability: Each layer can be tested independently
- Reusability: Models and controllers can be reused across different views
- Scalability: Easy to add new features following the MVC pattern
Migration Timeline
Week 1-2: Extract models (User, Detection, Dashboard) Week 3-4: Extract controllers (UserController, DetectionController) Week 5-6: Extract views (components and pages) Week 7-8: Extract services (API services, authentication) Week 9-10: Update Remix routes and integration Week 11-12: Testing, optimization, and documentation
Implementation Considerations
- State Management: Use models as single source of truth
- Event Handling: Implement observer pattern for model updates
- Error Handling: Centralize error handling in controllers
- Testing: Test each layer independently
- Documentation: Document the MVC structure and responsibilities
Potential Challenges
- Complexity: MVC can add complexity for simple applications
- Boilerplate: More code required for simple operations
- Learning Curve: Team needs to understand MVC principles
- Performance: Multiple layers might impact performance
- Over-engineering: Risk of over-engineering simple features
Success Metrics
- Code Organization: Percentage of code properly organized by MVC layers
- Separation of Concerns: Reduction in tightly coupled code
- Test Coverage: Test coverage for each MVC layer
- Development Velocity: Time to implement new features
- Bug Reduction: Reduction in bugs due to better separation of concerns