Service injection is one of the most powerful patterns for building maintainable applications, and Juris.js makes it remarkably straightforward. Unlike other frameworks that require complex dependency injection containers or convoluted setup, Juris provides clean, intuitive ways to inject services into your enhanced components.
This guide will take you from basic service patterns to advanced architectural approaches, helping you build scalable applications with clean separation of concerns.
What Are Services and Why Inject Them?
Services are objects that encapsulate business logic, API calls, data management, or any functionality that shouldn't live directly in your UI components. Service injection is the practice of providing these services to components without tightly coupling them together.
The Problem Without Service Injection
// Tightly coupled - hard to test and maintain
app.enhance('#user-profile', (props, { getState, setState }) => ({
textContent: () => getState('user.name'),
onClick: async () => {
// Business logic mixed with UI logic
try {
const response = await fetch('/api/user/profile', {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
const userData = await response.json();
setState('user', userData);
// Analytics tracking
gtag('event', 'profile_viewed', { user_id: userData.id });
// Logging
console.log('User profile loaded:', userData.email);
} catch (error) {
setState('error', 'Failed to load profile');
}
}
}));
The Solution: Service Injection
// Clean separation - services injected, components focus on UI
app.enhance('#user-profile', (props, { getState, setState, services }) => ({
textContent: () => getState('user.name'),
onClick: async () => {
try {
const userData = await services.api.getUserProfile();
setState('user', userData);
services.analytics.track('profile_viewed', { user_id: userData.id });
services.logger.info('User profile loaded', userData.email);
} catch (error) {
setState('error', 'Failed to load profile');
}
}
}));
Method 1: Global Service Configuration
The simplest approach is to configure services when creating your Juris instance:
// Define your services
const apiService = {
async getUserProfile() {
const token = localStorage.getItem('token');
const response = await fetch('/api/user/profile', {
headers: { 'Authorization': `Bearer ${token}` }
});
return response.json();
},
async updateUser(userData) {
const token = localStorage.getItem('token');
const response = await fetch('/api/user', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(userData)
});
return response.json();
}
};
const authService = {
getCurrentUser() {
return JSON.parse(localStorage.getItem('user') || 'null');
},
logout() {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login';
},
isAuthenticated() {
return !!localStorage.getItem('token');
}
};
const notificationService = {
show(message, type = 'info') {
// Your notification implementation
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
};
// Configure Juris with services
const app = new Juris({
services: {
api: apiService,
auth: authService,
notifications: notificationService
}
});
// Use services in components
app.enhance('#dashboard', (props, { getState, setState, services }) => ({
textContent: () => {
const user = services.auth.getCurrentUser();
return user ? `Welcome, ${user.name}!` : 'Please log in';
},
onClick: async () => {
if (!services.auth.isAuthenticated()) {
services.notifications.show('Please log in first', 'warning');
return;
}
try {
const profile = await services.api.getUserProfile();
setState('userProfile', profile);
services.notifications.show('Profile loaded successfully', 'success');
} catch (error) {
services.notifications.show('Failed to load profile', 'error');
}
}
}));
Method 2: Provider Pattern for Nested Services
For more complex applications, you can use a provider pattern:
// Service container with nested services
const serviceContainer = {
data: {
users: {
async getById(id) { /* implementation */ },
async update(id, data) { /* implementation */ },
async delete(id) { /* implementation */ }
},
posts: {
async getByUser(userId) { /* implementation */ },
async create(postData) { /* implementation */ }
}
},
ui: {
modal: {
open(content) { /* implementation */ },
close() { /* implementation */ }
},
loader: {
show() { /* implementation */ },
hide() { /* implementation */ }
}
},
utils: {
formatDate(date) { /* implementation */ },
validateEmail(email) { /* implementation */ }
}
};
// Provide services to the entire app
app.provide('services', serviceContainer);
// Access services in components
app.enhance('#user-management', (props, { getState, setState, inject }) => {
const services = inject('services');
return {
onClick: async () => {
services.ui.loader.show();
try {
const userId = getState('selectedUser.id');
const user = await services.data.users.getById(userId);
const posts = await services.data.posts.getByUser(userId);
setState('currentUser', user);
setState('userPosts', posts);
services.ui.modal.open(`User: ${user.name}`);
} catch (error) {
console.error('Error loading user:', error);
} finally {
services.ui.loader.hide();
}
}
};
});
Method 3: Module-Based Service Architecture
For large applications, organize services as ES6 modules:
// services/api.js
export class ApiService {
constructor(baseUrl, authService) {
this.baseUrl = baseUrl;
this.authService = authService;
}
async request(endpoint, options = {}) {
const url = `${this.baseUrl}${endpoint}`;
const headers = {
'Content-Type': 'application/json',
...options.headers
};
if (this.authService.isAuthenticated()) {
headers.Authorization = `Bearer ${this.authService.getToken()}`;
}
const response = await fetch(url, { ...options, headers });
if (!response.ok) {
throw new Error(`API Error: ${response.status}`);
}
return response.json();
}
// Specific API methods
getUsers() { return this.request('/users'); }
getUser(id) { return this.request(`/users/${id}`); }
updateUser(id, data) { return this.request(`/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }); }
}
// services/auth.js
export class AuthService {
getToken() {
return localStorage.getItem('token');
}
isAuthenticated() {
return !!this.getToken();
}
async login(credentials) {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials)
});
const data = await response.json();
if (data.token) {
localStorage.setItem('token', data.token);
localStorage.setItem('user', JSON.stringify(data.user));
}
return data;
}
}
// services/container.js
import { ApiService } from './api.js';
import { AuthService } from './auth.js';
const authService = new AuthService();
const apiService = new ApiService('/api', authService);
export const serviceContainer = {
auth: authService,
api: apiService,
// Environment-specific services
config: {
apiUrl: import.meta.env.VITE_API_URL || '/api',
isDevelopment: import.meta.env.DEV
}
};
// main.js
import { serviceContainer } from './services/container.js';
const app = new Juris({
services: serviceContainer
});
// Use in components
app.enhance('#login-form', (props, { getState, setState, services }) => ({
onSubmit: async (event) => {
event.preventDefault();
const email = getState('login.email');
const password = getState('login.password');
try {
setState('login.loading', true);
const result = await services.auth.login({ email, password });
if (result.token) {
window.location.href = '/dashboard';
}
} catch (error) {
setState('login.error', error.message);
} finally {
setState('login.loading', false);
}
}
}));
Advanced Pattern: Service Factories and Configuration
For maximum flexibility, use service factories that can be configured based on environment or context:
// Service factories
const createApiService = (config) => ({
async request(endpoint, options = {}) {
const url = `${config.baseUrl}${endpoint}`;
// Implementation using config
}
});
const createCacheService = (strategy = 'memory') => {
if (strategy === 'memory') {
const cache = new Map();
return {
get: (key) => cache.get(key),
set: (key, value) => cache.set(key, value),
clear: () => cache.clear()
};
} else if (strategy === 'localStorage') {
return {
get: (key) => JSON.parse(localStorage.getItem(key) || 'null'),
set: (key, value) => localStorage.setItem(key, JSON.stringify(value)),
clear: () => localStorage.clear()
};
}
};
// Environment-based service configuration
const createServices = (environment) => {
const config = {
development: {
apiUrl: 'http://localhost:3000/api',
cacheStrategy: 'memory',
enableLogging: true
},
production: {
apiUrl: 'https://api.myapp.com',
cacheStrategy: 'localStorage',
enableLogging: false
}
}[environment];
return {
api: createApiService({ baseUrl: config.apiUrl }),
cache: createCacheService(config.cacheStrategy),
logger: config.enableLogging ? console : { log: () => {}, warn: () => {}, error: () => {} }
};
};
// Initialize with environment-specific services
const app = new Juris({
services: createServices(process.env.NODE_ENV || 'development')
});
Service Composition and Dependencies
Services can depend on other services, creating a clean dependency graph:
const createServiceContainer = () => {
// Base services
const logger = {
info: (msg, data) => console.log(`[INFO] ${msg}`, data),
error: (msg, error) => console.error(`[ERROR] ${msg}`, error)
};
const http = {
async get(url) { return fetch(url).then(r => r.json()); },
async post(url, data) { return fetch(url, { method: 'POST', body: JSON.stringify(data) }).then(r => r.json()); }
};
// Composed services that depend on base services
const userService = {
async getUser(id) {
logger.info('Fetching user', { id });
try {
return await http.get(`/api/users/${id}`);
} catch (error) {
logger.error('Failed to fetch user', error);
throw error;
}
}
};
const notificationService = {
async sendWelcome(userId) {
logger.info('Sending welcome notification', { userId });
const user = await userService.getUser(userId);
return http.post('/api/notifications', {
to: user.email,
template: 'welcome',
data: { name: user.name }
});
}
};
return {
logger,
http,
user: userService,
notifications: notificationService
};
};
const app = new Juris({
services: createServiceContainer()
});
Testing with Service Injection
Service injection makes testing incredibly easy:
// Test with mock services
const createMockServices = () => ({
api: {
getUserProfile: jest.fn().mockResolvedValue({ id: 1, name: 'Test User' })
},
auth: {
isAuthenticated: jest.fn().mockReturnValue(true)
},
notifications: {
show: jest.fn()
}
});
const testApp = new Juris({
services: createMockServices()
});
// Your component works exactly the same with mock services
testApp.enhance('#test-component', (props, { services }) => ({
onClick: async () => {
const user = await services.api.getUserProfile();
services.notifications.show(`Hello, ${user.name}!`);
}
}));
Best Practices and Patterns
1. Keep Services Focused
Each service should have a single responsibility:
// Good: Focused services
const services = {
auth: { login, logout, isAuthenticated },
api: { get, post, put, delete },
storage: { get, set, remove },
validation: { email, phone, required }
};
// Avoid: God object services
const services = {
everything: {
login, logout, apiCall, validate, store, format, calculate
}
};
2. Use Interfaces for Consistency
Define clear interfaces for your services:
// Service interface
const createApiService = (implementation) => ({
async getUser(id) { return implementation.getUser(id); },
async updateUser(id, data) { return implementation.updateUser(id, data); }
});
// Different implementations
const restApiService = createApiService({
getUser: async (id) => fetch(`/api/users/${id}`).then(r => r.json()),
updateUser: async (id, data) => fetch(`/api/users/${id}`, { method: 'PUT', body: JSON.stringify(data) })
});
const graphqlApiService = createApiService({
getUser: async (id) => graphqlClient.query({ query: GET_USER, variables: { id } }),
updateUser: async (id, data) => graphqlClient.mutate({ mutation: UPDATE_USER, variables: { id, data } })
});
3. Handle Service Errors Gracefully
Always handle service failures:
app.enhance('#robust-component', (props, { getState, setState, services }) => ({
onClick: async () => {
try {
setState('loading', true);
const data = await services.api.getData();
setState('data', data);
} catch (error) {
services.logger.error('Failed to load data', error);
setState('error', 'Something went wrong. Please try again.');
} finally {
setState('loading', false);
}
}
}));
Conclusion
Service injection in Juris.js provides powerful architectural patterns while maintaining simplicity:
- Global Configuration - Simple setup for straightforward apps
- Provider Pattern - Flexible service management for complex applications
- Module Architecture - Scalable organization for large codebases
- Service Factories - Environment-specific and configurable services
- Clean Testing - Easy mocking and isolated testing
The key benefits you gain:
- Separation of Concerns - UI components focus on presentation
- Testability - Services can be easily mocked and tested
- Reusability - Services work across multiple components
- Maintainability - Changes to business logic don't affect UI code
- Flexibility - Swap implementations without changing components
By leveraging Juris.js's service injection patterns, you can build applications that are both powerful and maintainable, with clean separation between your business logic and user interface. The framework's approach makes dependency injection feel natural and intuitive, without the complexity often associated with such patterns.
Top comments (3)
Awesome :-)
Looks like Juris got a new fan! HeadsUp! You will get rewarded of learning Juris but at the same time you we'll get addicted to. Cheers
Yes I am a big fan of Juris :)