Hello! Today I would like to discuss my journey of creating an Avatar component and its evolution by applying best OOP practises.
In this article, we'll explore:
- Basic avatar implementation
- Evolution to clean architecture
- Why certain patterns were chosen
- Common pitfalls and solutions
- Real-world considerations
Let's start.
Introduction
So, imagine you have a task, create an Avatar component that represents some user in the system. It will look like this UI wise.
Nothing really complicated right?
As an experienced developer, you might right away start to asses what data would you need for that component.
You would need probably some object that will hold username(tooltip purposes), backgroundImageUrl, size(how big you avatar would be).
So, let's create this object and define it in our system by something like this
export class AvatarConfiguration {
public backgroundColor: string = '#666666';
public backgroundImageUrl: string | null = null;
public size: number | AVATAR_SIZE = AVATAR_SIZE.m;
public fontSize: number | TEXT_SIZE = TEXT_SIZE.m;
public cssClass: string = '';
public email: string = '';
public username: string = '';
public placeholder: string = '';
public id: string = '';
}
And then we might as well define and AvatarComponent as well that gets this info via @Input().
@Component({
selector: 'app-avatar',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="avatar avatar--size-{{ config.size }}">
@if(!!config.backgroundImageUrl) {
<span class="avatar_image"
[style.background-image]="
'url('+ config.backgroundImageUrl + ')'
"></span>
} @else {
<span
class="avatar_placeholder
avatar_placeholder--{{ config.size | avatarSizeModifiers }}">
{{ config.placeholder }}
</span>
}
</div>
`
})
export class AvatarComponent {
@Input({ required: true }) config!: AvatarConfiguration;
}
And final piece of the puzzle, as we need somehow to build this configuration and we don't want to create that configuration in a lot of places, we might as well implement very handy pattern called Builder
export class AvatarConfigurationBuilder {
public config: AvatarConfiguration;
constructor() {
this.config = this.createConfig();
}
public withUsername(username: string) {
this.config.username = username;
return this;
}
public withPlaceholder(username: string) {
this.config.placeholder = this.createPlaceholder(username);
return this;
}
public withBackgroundImageUrl(imageUrl: string) {
this.config.backgroundImageUrl = imageUrl;
return this;
}
public withSize(size: number | AVATAR_SIZE) {
this.config.size = +size;
return this;
}
public build(): AvatarConfiguration {
return this.config;
}
public createConfig(): AvatarConfiguration {
return new AvatarConfiguration() as T;
}
...other methods
}
As you can see we can already create some configurations, we made this builder to be responsible for creation of that AvatarConfiguration and we are free to go!
In the code we already can use that builder to create our beloved AvatarConfiguration, we can just use
new AvatarConfigurationBuilder()
.withPlaceholder('placeholder')
.withBackgroundImageUrl('some url)
.build()
and it will return as what we need.
Conclusions
There are no problems if our implementation stays still. But as we know, usually times throws new challenges(also known as features).
What I particularly faced was upload functionality.
And here where I have faced a lot of problems and challenges.
Journey begins
First you might think, what is the big problem, you can add corresponding functionality to the avatar.component and easy peasy.
Not so fast.
export class AvatarComponent {
constructor(private storage: Storage) {} // 😱 Direct Firebase dependency
async uploadNewImage() {
const input =
document.createElement('input'); // 😱 Direct DOM manipulation
input.type = 'file';
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files![0];
// 😱 Direct Firebase upload
const storageRef = ref(this.storage, file.name);
const uploadTask = uploadBytesResumable(storageRef, file);
// ... upload logic
};
input.click();
...rest of the logic
}
}
This is a working solution and if you satisfied with it, it is totally fine. Most people do like this, implement something right away, so it works and then stop. I completely understand that someone might have short deadlines or other reasons, but what is important that result still stays the same. *Awfully complicated tries to maintain this code.
*
So logical question how to make this code maintainable?
First I would start by creating an abstract representation of our beloved Avatar.
export abstract class Avatar {
public configuration!: AvatarConfiguration;
protected constructor(config: AvatarConfiguration) {
this.configuration = config;
}
}
Simple enough. Ass you could have already seen, AvatarConfiguration is just a simple class that holds a bunch of values for Avatar, and it is very suitable as a part of Avatar and NOT its representation.
Then next logical step would be define an Avatar with upload capabilities.
// following Interface Segregation principle!
interface WithUpload {
fileUploadImplementation: FileUpload;
fileExtractorImplementation: FileExtractor;
uploadFn: () => Promise<string>;
}
export abstract class AvatarWithFileUpload extends Avatar implements WithUpload {
public uploadFn: () => Promise<string>;
protected constructor(
config: AvatarConfiguration,
public fileUploadImplementation: FileUpload,
public fileExtractorImplementation: FileExtractor
) {
super(config);
this.uploadFn = () =>
firstValueFrom(
this.fileExtractorImplementation.getFiles(true).pipe(
switchMap(files => {
return this.fileUploadImplementation
.uploadFileToStorage(files[0]).pipe(
map(downloadUrl => {
this.configuration.backgroundImageUrl = downloadUrl!;
return downloadUrl!;
})
);
}),
take(1)
)
);
}
}
So as you can see uploadFn() which represent an ability of Avatar
to change it's background image and in meanwhile relying on only abstractions.
Let me show you how FileUpload and FileExtractor are built.
MORE ABSTRACTIONS!!!
I know it's already sounds like over complication but bear with me!
FileExtractor
export abstract class FileExtractor {
public abstract getFiles(single?: boolean, supportedFileTypes?: string[]): Observable<File[]>;
}
As you can see it is a simple contract that simply states - "You give me some options, I give you stream with files". That's how we can abstract from any specifics of OS and browser, as long as they can guarantee consistency with interfaces(Observable, File).
FileUpload
export abstract class FileUpload {
public abstract uploadFileToStorage(file: File): Observable<string | null>;
}
The same goes here, we can possibly upload to any storage in the world.
Now final piece of abstraction cake is to define something that will give Avatars I want, basic one and the ONE with upload functionality.
export abstract class AvatarFactory {
abstract createAvatar
(user: User, params?: Partial<AvatarConfiguration>): Avatar;
abstract createAvatarWithUpload
(user: User, params?: Partial<AvatarConfiguration>): AvatarWithFileUpload;
}
The power of this cannot be underestimated. This code will be working in any framework where typescript is used and easily converted into library.
You might be already questioning where is Angular in all of this.
Now let me show how this code can work with Angular.
Dive back to Angular
So in my real case scenario, it was an angular app for music school called Touche. So my Avatar now has some context and can building its own representation in Touche angular app.
Parade of implementations, or where the fun begins:
(I put all abstractions and implementations in one file for simplicity, they can be easily separated and that's great!)
Avatar
export abstract class Avatar {
public configuration!: AvatarConfiguration;
protected constructor(config: AvatarConfiguration) {
this.configuration = config;
}
}
// real implementation of avatar in Touche project
export class ToucheAvatar extends Avatar {
constructor(config: AvatarConfiguration) {
super(config);
}
}
Avatar with upload capabilities!
export class ToucheAvatarWithFileUpload extends AvatarWithFileUpload {
constructor(
config: AvatarConfiguration,
fileUploadImplementation: FileUpload,
fileExtractorImplementation: FileExtractor
) {
super(config, fileUploadImplementation, fileExtractorImplementation);
}
}
as a reminder, abstract counterpart =>
export abstract class AvatarWithFileUpload extends Avatar implements WithUpload {
public uploadFn: () => Promise<string>;
protected constructor(
config: AvatarConfiguration,
public fileUploadImplementation: FileUpload,
public fileExtractorImplementation: FileExtractor
) {
super(config);
this.uploadFn = () =>
...default upload code
);
}
}
AvatarsFactory
Main place where all everything is combined
// will come in handy later!!!
@Injectable()
export class ToucheAvatarFactory implements AvatarFactory {
constructor(
private avatarConfigurationBuilderFactory: AvatarConfigurationBuilderFactory,
private fileUpload: FileUpload,
private fileExtractor: FileExtractor
) {
super();
}
// basic avatar
public createAvatar(user: User, params?: Partial<AvatarConfiguration>): Avatar {
return new ToucheAvatar(this.getDefaultAvatarConfiguration(user, params));
}
// upload avatar
public createAvatarWithUpload(user: User, params?: Partial<AvatarConfiguration>): AvatarWithFileUpload {
return new ToucheAvatarWithFileUpload(
this.getDefaultAvatarConfiguration(user, params),
this.fileUpload,
this.fileExtractor
);
}
private getDefaultAvatarConfiguration(user: User, params?: Partial<AvatarConfiguration>): AvatarConfiguration {
return this.avatarConfigurationBuilderFactory
.getBuilder()
.withId(user.uid)
.withUsername(user!.displayName ?? user!.email ?? '')
.withPlaceholder(user!.displayName ?? user!.email ?? '')
.withSize(params?.size ?? AVATAR_SIZE.s)
.withBackgroundImageUrl(user!.backgroundImageUrl)
.build();
}
}
// as a reminder of abstraction counterpart
export abstract class AvatarFactory {
abstract createAvatar(user: User, params?: Partial<AvatarConfiguration>): Avatar;
abstract createAvatarWithUpload(user: User, params?: Partial<AvatarConfiguration>): AvatarWithFileUpload;
}
Final setup
We want to finally use all these concrete implementations in UI!
That's the place where Angular DI system comes into play.
const ABSTRACTIONS: Provider[] = [
{
provide: FileExtractor,
useClass: ToucheFileExtractor
},
{
provide: FileUpload,
useClass: ToucheFileUpload
},
{
provide: AvatarFactory,
useClass: ToucheAvatarFactory
},
];
export const applicationConfig: ApplicationConfig = {
providers: [ABSTRACTIONS]
}
and register them in the providers of new ApplicationConfig property of bootstrapApplication function(standalone architecture) or in appModule.
And now, we can use this factory to get avatars in any @Injectable() service, using abstract class as a reference to concrete implementation!
@Injectable({providedIn: 'root')
export class SomeService
constructor(
private store: Store<TimeSlotsState>,
private avatarFactory: AvatarFactory// Abstraction here!!!
)
}
So now, by properly setting up abstractions in Angular DI, we are only relying on abstractions and not implementations!
Tell me what you think in the comments!
Top comments (0)