DEV Community

Aleks Onyshko
Aleks Onyshko

Posted on

Framework agnostic Avatar component

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.

Image description

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 = '';
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
        )
      );
  }
}
Enter fullscreen mode Exit fullscreen mode

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[]>;
} 
Enter fullscreen mode Exit fullscreen mode

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>;
}

Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}

Enter fullscreen mode Exit fullscreen mode

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
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}

Enter fullscreen mode Exit fullscreen mode

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]
}

Enter fullscreen mode Exit fullscreen mode

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!!!
  )
}
Enter fullscreen mode Exit fullscreen mode

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)