Service Structure
Services use the moderninject() function and signal-based state:
@Injectable()
export class UserService {
readonly #activatedRoute = inject(ActivatedRoute);
readonly #supabaseClient = inject(SUPABASE_CLIENT);
readonly #loggerService = inject(LoggerService);
readonly #user: WritableSignal<null | User>;
public constructor() {
this.#user = signal(null);
this.#supabaseClient.auth.onAuthStateChange(
(_authChangeEvent: AuthChangeEvent, authSession: AuthSession | null): void => {
this.#user.set(authSession?.user ?? null);
},
);
this.#loggerService.logServiceInitialization('UserService');
}
public get user(): Signal<null | User> {
return this.#user.asReadonly();
}
}
Service Scopes
Root-Scoped Services
Services available throughout the application:@Injectable({ providedIn: 'root' })
export class SettingsService {
readonly #loggerService = inject(LoggerService);
readonly #storageService = inject(StorageService);
readonly #settings: WritableSignal<Settings>;
public readonly directionality: Signal<Settings['languageOption']['directionality']>;
public constructor() {
const storedSettings: null | Settings = this.#storageService.getLocalStorageItem<Settings>(
LocalStorageKey.Settings,
);
this.#settings = signal({ ...DEFAULT_SETTINGS, ...storedSettings });
this.directionality = computed(() => this.#settings().languageOption.directionality);
effect(
() => {
this.#loggerService.logEffectRun('settings');
const settings: Settings = this.#settings();
untracked(() =>
this.#storageService.setLocalStorageItem(LocalStorageKey.Settings, settings),
);
},
{ debugName: 'settings' },
);
this.#loggerService.logServiceInitialization('SettingsService');
}
public get settings(): Signal<Settings> {
return this.#settings.asReadonly();
}
public updateSettings(partialSettings: Partial<Settings>): void {
this.#settings.update((settings) => ({ ...settings, ...partialSettings }));
}
}
Route-Scoped Services
Services provided at the route level for scoped data:export const lazyRoutes: Routes = [
{
children: [...mainRoutes, ...userRoutes],
path: '',
providers: [UserService, ProfileService]
},
];
Service Patterns
State Management with Signals
@Injectable({ providedIn: 'root' })
export class ProgressBarService {
readonly #loggerService = inject(LoggerService);
readonly #progressBarConfiguration: WritableSignal<ProgressBarConfiguration>;
#queueTimeoutId: number | undefined;
public constructor() {
this.#progressBarConfiguration = signal({
bufferValue: 0,
isVisible: false,
mode: 'indeterminate',
value: 0,
});
this.#queueTimeoutId = undefined;
this.#loggerService.logServiceInitialization('ProgressBarService');
}
public get progressBarConfiguration(): Signal<ProgressBarConfiguration> {
return this.#progressBarConfiguration.asReadonly();
}
public hideProgressBar(): void {
this.#queueConfiguration({ isVisible: false });
}
public showIndeterminateProgressBar(): void {
this.#queueConfiguration({ isVisible: true, mode: 'indeterminate' });
}
public showQueryProgressBar(): void {
this.#queueConfiguration({ isVisible: true, mode: 'query' });
}
#queueConfiguration(partialProgressBarConfiguration: Partial<ProgressBarConfiguration>): void {
clearTimeout(this.#queueTimeoutId);
this.#queueTimeoutId = setTimeout(() => {
this.#progressBarConfiguration.update((progressBarConfiguration) => ({
...progressBarConfiguration,
...partialProgressBarConfiguration,
}));
}, 90);
}
}
Data Services with Supabase
@Injectable()
export class ProfileService {
readonly #supabaseClient = inject(SUPABASE_CLIENT);
readonly #loggerService = inject(LoggerService);
readonly #userService = inject(UserService);
readonly #user: Signal<null | User>;
public constructor() {
this.#user = this.#userService.user;
this.#loggerService.logServiceInitialization('ProfileService');
}
public selectProfile() {
return this.#supabaseClient
.from(SupabaseTable.Profiles)
.select()
.eq('user_id', this.#user()?.id)
.single()
.throwOnError();
}
public updateAndSelectProfile(partialProfile: Partial<Profile>) {
return this.#supabaseClient
.from(SupabaseTable.Profiles)
.update(partialProfile)
.eq('user_id', this.#user()?.id)
.select()
.single()
.throwOnError();
}
public uploadAvatar(
file: File,
): Promise<
| { data: { fullPath: string; id: string; path: string }; error: null }
| { data: null; error: StorageError }
> {
const fileExtension: string | undefined = file.name.split('.').pop();
const timestamp: number = Date.now();
const path: string = `${this.#userService.user()?.id}/avatar-${timestamp}.${fileExtension}`;
return this.#supabaseClient.storage.from(SupabaseStorage.ProfileAvatars).upload(path, file);
}
}
Authentication Service
@Injectable()
export class UserService {
readonly #activatedRoute = inject(ActivatedRoute);
readonly #supabaseClient = inject(SUPABASE_CLIENT);
readonly #loggerService = inject(LoggerService);
readonly #user: WritableSignal<null | User>;
public constructor() {
this.#user = signal(null);
this.#supabaseClient.auth.onAuthStateChange(
(_authChangeEvent: AuthChangeEvent, authSession: AuthSession | null): void => {
this.#user.set(authSession?.user ?? null);
},
);
this.#loggerService.logServiceInitialization('UserService');
}
public signInWithPassword(email: string, password: string): Promise<AuthTokenResponsePassword> {
return this.#supabaseClient.auth.signInWithPassword({ email, password });
}
public signInWithOtp(email: string): Promise<AuthOtpResponse> {
return this.#supabaseClient.auth.signInWithOtp({
email,
options: { emailRedirectTo: this.#getRedirectUrlWithReturnUrl(), shouldCreateUser: false },
});
}
public signOut(): Promise<{ error: AuthError | null }> {
return this.#supabaseClient.auth.signOut();
}
public signUp(email: string, password: string): Promise<AuthResponse> {
return this.#supabaseClient.auth.signUp({
email,
options: { emailRedirectTo: this.#getRedirectUrlWithReturnUrl() },
password,
});
}
}
Storage Services
Abstract browser storage APIs:@Injectable({ providedIn: 'root' })
export class StorageService {
readonly #loggerService = inject(LoggerService);
readonly #store2: StoreType;
public constructor() {
this.#store2 = store2.namespace('jet');
this.#loggerService.logServiceInitialization('StorageService');
}
public getLocalStorageItem<T>(localStorageKey: LocalStorageKey): null | T {
return this.#store2.get(localStorageKey);
}
public setLocalStorageItem<T>(localStorageKey: LocalStorageKey, data: T): void {
this.#store2.set(localStorageKey, data);
}
public removeLocalStorageItem(localStorageKey: LocalStorageKey): void {
this.#store2.remove(localStorageKey);
}
public clearLocalStorage(): void {
store2.clearAll();
}
}
Alert Services
User notifications and feedback:@Injectable({ providedIn: 'root' })
export class AlertService {
readonly #matSnackBar = inject(MatSnackBar);
readonly #translocoService = inject(TranslocoService);
readonly #loggerService = inject(LoggerService);
readonly #settingsService = inject(SettingsService);
readonly #directionality: Signal<Settings['languageOption']['directionality']>;
public constructor() {
this.#directionality = this.#settingsService.directionality;
this.#loggerService.logServiceInitialization('AlertService');
}
public showAlert(
message: string,
cta: string = this.#translocoService.translate('alerts.ok'),
action?: () => void,
): void {
const matSnackBarRef: MatSnackBarRef<TextOnlySnackBar> = this.#matSnackBar.open(message, cta, {
direction: this.#directionality(),
});
if (action) {
matSnackBarRef
.onAction()
.pipe(take(1))
.subscribe(() => {
action();
});
}
}
public showErrorAlert(
message: string = this.#translocoService.translate('alerts.something-went-wrong'),
): void {
this.showAlert(message);
}
}
Logging Service
Centralized logging with conditional output:@Injectable({ providedIn: 'root' })
export class LoggerService {
readonly #isLoggingEnabled = inject(IS_LOGGING_ENABLED);
public logServiceInitialization(serviceName: string): void {
if (!this.#isLoggingEnabled) {
return;
}
console.info(`Service ${serviceName} initialized.`);
}
public logComponentInitialization(componentName: string): void {
if (!this.#isLoggingEnabled) {
return;
}
console.debug(`Component ${componentName} initialized.`);
}
public logError(error: Error): void {
if (!this.#isLoggingEnabled) {
return;
}
console.error(error);
}
public logException(exception: unknown): void {
if (!this.#isLoggingEnabled) {
return;
}
console.error(exception);
}
}
Custom Injection Tokens
Jet uses injection tokens for configuration:readonly #supabaseClient = inject(SUPABASE_CLIENT);
readonly #isLoggingEnabled = inject(IS_LOGGING_ENABLED);
Services are automatically provided in the dependency injection tree based on their
@Injectable decorator configuration.Service Best Practices
- Use Signals: Prefer signals over BehaviorSubjects for state management
- Readonly Signals: Expose readonly signals to prevent external mutations
- Private Fields: Use
#prefix for private fields and methods - Logging: Log service initialization for debugging
- Type Safety: Always type service methods and return values
- Error Handling: Services should throw or return errors, not handle them
- Single Responsibility: Each service should have a clear, focused purpose
Avoid circular dependencies between services. Use injection tokens or restructure service hierarchy if needed.