diff --git a/src/php/ModelGenerator.php b/src/php/ModelGenerator.php
index 945af5ad038a58b7f9eb555dcc5c348bbe0ca565..351029d3b964959a8df34c6d056b306800387955 100644
--- a/src/php/ModelGenerator.php
+++ b/src/php/ModelGenerator.php
@@ -149,7 +149,6 @@ final class ModelGenerator
         }
         sort($context['repoImports']);
 
-        $this->generateFile($writer, 'common.ts', $context);
         $this->generateFile($writer, 'resources.ts', $context);
         $this->generateFile($writer, 'repositories.ts', $context);
         $this->generateFile($writer, 'metadata.ts', $context);
diff --git a/src/php/Resources/views/common.ts.twig b/src/php/Resources/views/common.ts.twig
deleted file mode 100644
index b03fe268f5648edf8eced428c4d733314e5a656f..0000000000000000000000000000000000000000
--- a/src/php/Resources/views/common.ts.twig
+++ /dev/null
@@ -1,418 +0,0 @@
-{% extends '@NgModelGenerator/_layout.ts.twig' %}
-
-{% block content %}
-import {HttpClient, HttpHeaders, HttpResponseBase} from '@angular/common/http';
-import { forkJoin, Observable } from 'rxjs';
-
-/**
- * Nom de la propriété d'une resource contenant son IRI.
- */
-export const IRI_PROPERTY = '@id';
-
-/**
- * Nom de la propriété d'une resource contenant son type.
- */
-export const TYPE_PROPERTY = '@type';
-
-/**
- * Nom de la propriété contenant les resource d'une collection.
- */
-export const COLLECTION_MEMBERS = 'hydra:member';
-
-/**
- * Nom de la propriété contenant le nombre total d'objet d'une collection.
- */
-export const COLLECTION_TOTAL_COUNT = 'hydra:totalItems';
-
-/**
- * IRI typé.
- *
- * Internationalized Resource Identifier - RFC 3987
- */
-const IRI = Symbol('IRI');
-/* Les IRI sont en fait des chaînes mais pour forcer un typage fort on les définit
- * comme un type "opaque". De cette façon, il est impossible de mélanger
- * IRI et chaînes, et le type générique R permet d'interdire les assignations entre
- * IRI de resources différentes.
- */
-export interface IRI<R extends Resource> {
-  readonly [IRI]?: R;
-}
-
-/**
- * Resource
- */
-export interface Resource {
-  readonly [IRI_PROPERTY]: IRI<any>;
-  readonly [TYPE_PROPERTY]: string;
-  [property: string]: any;
-};
-
-/**
- * Collection représente une collection de respoucres JSON-LD pour un type T donné.
- */
-export interface Collection<R extends Resource> {
-  [COLLECTION_MEMBERS]: R[];
-  [COLLECTION_TOTAL_COUNT]: number;
-  [property: string]: any;
-}
-
-/**
- * Retourne les membres d'une collection.
- */
-export function getCollectionMembers<R extends Resource>(collection: Collection<R>): R[] {
-  return collection[COLLECTION_MEMBERS];
-}
-
-/**
- * Retourne le nombre total d'items
- * @param collection
- */
-export function getCollectionTotalCount<R extends Resource>(collection: Collection<R>): number {
-  return collection[COLLECTION_TOTAL_COUNT];
-}
-
-/**
- * Universally Unique IDentifier - RFC 4122
- */
-export type UUID = string;
-
-/**
- * Teste si une donnée (chaîne) est formatée selon un UUID.
- */
-export function isUUID(data: any): data is UUID {
-  return typeof data === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(data);
-}
-/**
- * Full DateTime in ISO-8601 format.
- */
-export type DateTime = string;
-
-/**
- * Options supplémentaires pour les requêtes.
- */
-export interface RequestOptions {
-  body?: any;
-  headers?: HttpHeaders | {
-    [header: string]: string | string[];
-  };
-  params?: { [param: string]: string | string[]; };
-}
-
-/**
- * AbstractResourceCache est une classe abstraite qui définit l'interface d'un cache de ressources.
- *
- * Elle doit être implémentée puis être fournie en provider d'un module core. Par exemple:
- *
- *      final class ResourceCache<T extends Resource> extends AbstractResourceCache<T> {
- *        // Implémentation
- *      }
- *
- *      providers: [
- *        [ provider: AbstractResourceCache, useClass: ResourceCache ],
- *      ]
- */
-export abstract class AbstractResourceCache {
-  /**
-   * Récupère une ressource par son IRI. N'exécute la requête requestFactory que si on ne dispose
-   * pas d'une version en cache.
-   */
-  public abstract get<R extends Resource>(
-    iri: IRI<R>,
-    requestFactory: () => Observable<R>
-  ): Observable<R>;
-
-  /**
-   * Met à jour une ressource existante, rafraîchit le cache local avec la réponse.
-   */
-  public abstract put<R extends Resource>(iri: IRI<R>, request: Observable<R>): Observable<R>;
-
-  /**
-   * Crée une nouvelle ressource et met la ressource créée dans le cache.
-   */
-  public abstract post<R extends Resource>(request: Observable<R>): Observable<R>;
-
-  /**
-   * Supprime une ressource en distant et dans le cache.
-   */
-  public abstract delete<R extends Resource>(iri: IRI<R>, request: Observable<HttpResponseBase>): Observable<HttpResponseBase>;
-
-  /**
-   * Effectue une recherche et met en cache toutes les ressources récupérées.
-   */
-  public abstract getAll<R extends Resource>(
-    request: Observable<Collection<R>>
-  ): Observable<Collection<R>>;
-
-  /**
-   * Invalide une ressource en cache.
-   */
-  public abstract invalidate<R extends Resource>(iri: IRI<R>): void;
-}
-
-/**
- * Type des paramètrès
- */
-type IRIParameter = string | number;
-export type IRIParameters = IRIParameter[] | IRIParameter;
-
-/**
- * Informations sur l'IRI d'une resource.
- *
- * P: types des paramètres.
- */
-export class IRIMetadata<P extends IRIParameters> {
-  public constructor(
-    private readonly testPattern: RegExp,
-    private readonly capturePattern: RegExp,
-    private readonly template: (parameters: P) => string
-  ) {}
-
-  public validate(path: string): boolean {
-    return this.testPattern.test(path);
-  }
-
-  public generate(parameters: P): IRI<any> {
-    return this.template(parameters) as any;
-  }
-
-  public parse(path: string): P {
-    const matches = this.capturePattern.exec(path);
-    if (!matches) {
-      throw new Error(`Invalid path: ${path} does not match ${this.capturePattern}`);
-    }
-    if (matches.length == 2) {
-      return matches[1] as any;
-    }
-    return matches.slice(1) as any;
-  }
-}
-
-function getResourceType(that: any): string | null {
-  return (
-    (that !== null && typeof that === 'object' && typeof that[TYPE_PROPERTY] === 'string' && that[TYPE_PROPERTY]) ||
-    null
-  );
-}
-
-/**
- * Metadonnées d'une ressource.
- *
- * R : type de resource, e.g. Person
- * T : valeur de la propriété '@type', e.g. 'Person'.
- */
-export class ResourceMetadata<R extends Resource, T extends string, P extends IRIParameters> {
-  public constructor(
-    public readonly type: T,
-    public readonly iri: IRIMetadata<P>,
-    private readonly requiredProperties: (keyof R)[],
-    private readonly types: ResourceMetadata<any, any, P>[] = []
-  ) {
-    this.types.unshift(this);
-  }
-
-  /**
-   * Vérifie si l'argument représente une ressource R ou dérivée.
-   */
-  public isResource(that: any): that is R {
-    const type = getResourceType(that);
-    if (!type) {
-      return false;
-    }
-    return this.types.some(t => t.type === type);
-  }
-
-  /**
-   * Vérifie si une propriété est obligatoire dans la resource R.
-   */
-  public isRequired(property: keyof R): boolean {
-    return this.requiredProperties.includes(property);
-  }
-
-  /**
-   * Génère une IRI à partir de ses paramètres.
-   */
-  public generateIRI(parameters: P): IRI<R> {
-    return this.iri.generate(parameters);
-  }
-
-  /**
-   * Extrait les paramètres d'une IRI.
-   */
-  public getIRIParameters(iri: IRI<R>): P {
-    return this.iri.parse(iri as any);
-  }
-}
-
-/**
- * Classe de base d'un repository.
- */
-export abstract class AbstractRepository<R extends Resource, T extends string, P extends IRIParameters> {
-  public constructor(
-    public readonly metadata: ResourceMetadata<R, T, P>,
-    protected readonly client: HttpClient,
-    protected readonly cache: AbstractResourceCache
-  ) {}
-
-  /**
-   * Génère une IRI à partir de ses paramètres.
-   */
-  public generateIRI(parameters: P): IRI<R> {
-    return this.metadata.generateIRI(parameters);
-  }
-
-  /**
-   * Extrait les paramètres d'une IRI.
-   */
-  public getIRIParameters(iri: IRI<R>): P {
-    return this.metadata.getIRIParameters(iri);
-  }
-}
-
-/**
- * Sur-type encadrant des API.
- */
-export interface APIMeta {
-  [type: string]: {
-    resource: Resource;
-    metadata: ResourceMetadata<any, any, IRIParameters>;
-    repository: AbstractRepository<any, any, IRIParameters>;
-    iriParameters: IRIParameters;
-  };
-}
-
-/**
- * Classe abstraite d'un registre des metadonnées des ressources.
- */
-export interface APIMetadataRegistry<API extends APIMeta> {
-  /**
-   * Vérifie si on a des métadonnées pour le type de ressourcce indiqué.
-   */
-  has<T extends keyof API>(type: T): type is T;
-
-  /**
-   * (Construit et) retourne l'instance de métadonnées pour le type de resource 'type'.
-   */
-  get<T extends keyof API>(type: T): API[T]['metadata'];
-}
-
-/**
- * Registre de métadonnées qui construit les instances à la demande (ce qui permet de gérer les
- * dépendances entre métadonnées).
- */
-export class LazyMetadataRegistry<API extends APIMeta> implements APIMetadataRegistry<API> {
-  private readonly instances: { [T in keyof API]?: API[T]['metadata'] } = {};
-
-  protected constructor(
-    private readonly builders: { readonly [T in keyof API]: (r?: APIMetadataRegistry<API>) => API[T]['metadata'] }
-  ) {}
-
-  public has<T extends keyof API>(type: T): type is T {
-    return typeof type === 'string' && type in this.builders;
-  }
-
-  public get<T extends keyof API>(type: T): API[T]['metadata'] {
-    if (!this.has(type)) {
-      throw new Error(`Invalid resource type: ${type}`);
-    }
-    return this.getOrCreate(type);
-  }
-
-  protected getOrCreate<T extends keyof API>(type: T): API[T]['metadata'] {
-    if (!(type in this.instances)) {
-      this.instances[type] = this.builders[type](this);
-    }
-    return this.instances[type];
-  }
-}
-
-/**
- * Classe abstraite de la façade de l'API.
- */
-export interface APIRepositoryRegistry<API extends APIMeta> {
-  get<T extends keyof API>(type: T): API[T]['repository'];
-}
-
-/**
- * Service permettant d'accéder à l'API.
- */
-export interface APIService<API extends APIMeta> {
-  /**
-   * Métadonnées de l'API.
-   */
-  readonly metadata: APIMetadataRegistry<API>;
-
-  /**
-   * Repositories de l'API
-   */
-  readonly repositories: APIRepositoryRegistry<API>;
-
-  /**
-   * Récupère une ressource par son IRI ou par son type et les paramètres de son IRI.
-   */
-  get<R extends Resource>(iri: IRI<R>, options?: RequestOptions): Observable<R> ;
-  get<T extends keyof API>(type: T, parameters: API[T]['iriParameters'], options?: RequestOptions): Observable<API[T]['resource']> ;
-
-  /**
-   * Récupère des ressources par leurs IRIs.
-   */
-  getMany<R extends Resource>(iris: IRI<R>[], options?: RequestOptions): Observable<R[]>;
-
-  /**
-   * Génère l'IRI d'une resource à partir de son type et des paramètres d'IRI.
-   */
-  generateIRI<T extends keyof API, P extends string[]>(type: T, parameters: P): IRI<any>;
-
-  /**
-   * Invalide le cache pour une IRI.
-   */
-  invalidate<R extends Resource>(iri: IRI<R>): void;
-}
-
-/**
- * Implémentation de base d'une api
- */
-export abstract class AbstractAPIService<
-  API extends APIMeta,
-  MR extends APIMetadataRegistry<API>,
-  RR extends APIRepositoryRegistry<API>
-> implements APIService<API> {
-  public constructor(
-    public readonly metadata: MR,
-    public readonly repositories: RR,
-    private readonly cache: AbstractResourceCache,
-    private readonly client: HttpClient
-  ) {}
-
-  public get<R extends Resource>(iri: IRI<R>, options?: RequestOptions): Observable<R>;
-  public get<T extends keyof API>(type: T, parameters: API[T]['iriParameters'], options?: RequestOptions): Observable<API[T]['resource']>;
-
-  public get<T extends keyof API, R extends Resource = API[T]['resource']>(
-    typeOrIRI: T | IRI<R>,
-    parametersOrOptions?: API[T]['iriParameters'] | RequestOptions,
-    options?: RequestOptions
-  ): Observable<R> {
-    let iri: IRI<R>;
-    if (this.metadata.has(typeOrIRI as string)) {
-      iri = this.metadata.get(typeOrIRI as string).generateIRI(parametersOrOptions as API[T]['iriParameters']);
-    } else {
-      iri = typeOrIRI as IRI<R>;
-      options = parametersOrOptions as RequestOptions;
-    }
-    return this.cache.get(iri, () => this.client.get<R>(iri as any, options));
-  }
-
-  public getMany<R extends Resource>(iris: IRI<R>[], options?: RequestOptions): Observable<R[]> {
-    return forkJoin(iris.map(iri => this.get(iri, options)));
-  }
-
-  public generateIRI<T extends keyof API>(type: T, parameters: API[T]['iriParameters']): IRI<API[T]['resource']> {
-    return this.metadata.get(type).generateIRI(parameters);
-  }
-
-  public invalidate<R extends Resource>(iri: IRI<R>): void {
-    this.cache.invalidate(iri);
-  }
-}
-
-{% endblock %}
diff --git a/src/php/Resources/views/index.ts.twig b/src/php/Resources/views/index.ts.twig
index 7940dacb54a4364d6f9a87267cece08a0d866763..ce383629cf677cd237d07b0a4386ab7b8a3e729e 100644
--- a/src/php/Resources/views/index.ts.twig
+++ b/src/php/Resources/views/index.ts.twig
@@ -1,7 +1,8 @@
 {% extends '@NgModelGenerator/_layout.ts.twig' %}
 
 {% block content %}
-export * from './common';
+export * from 'irstea-ng-model/types';
+
 export * from './resources';
 export * from './repositories';
 export * from './metadata';
diff --git a/src/php/Resources/views/metadata.ts.twig b/src/php/Resources/views/metadata.ts.twig
index ae30a26c5f579d0d62b687e3fd3c4e9a3f45404d..cc66efd1fe4d966f97ab1193d1f9942129105cf1 100644
--- a/src/php/Resources/views/metadata.ts.twig
+++ b/src/php/Resources/views/metadata.ts.twig
@@ -6,7 +6,6 @@
 {% autoescape false %}
 import { Injectable, Provider } from '@angular/core';
 import { HttpClient } from '@angular/common/http';
-
 import {
   AbstractAPIService,
   AbstractResourceCache,
@@ -16,7 +15,8 @@ import {
   LazyMetadataRegistry,
   ResourceMetadata,
   UUID,
-} from './common';
+} from 'irstea-ng-model/types';
+
 import {
 {% for repo in repositories %}
   {{ repo.usage }}{% if not loop.last %},{% endif %}
diff --git a/src/php/Resources/views/repositories.ts.twig b/src/php/Resources/views/repositories.ts.twig
index 688e7c0c2da98845ebee723c966dc4328309b331..9ea2f740da2e641cbafd2ef101ed990534905e71 100644
--- a/src/php/Resources/views/repositories.ts.twig
+++ b/src/php/Resources/views/repositories.ts.twig
@@ -8,7 +8,7 @@ import { Observable } from 'rxjs';
 import { HttpResponseBase } from '@angular/common/http';
 
 // @ts-ignore
-import { AbstractRepository, Collection, IRI, RequestOptions, UUID } from './common';
+import { AbstractRepository, Collection, IRI, RequestOptions, UUID } from 'irstea-ng-model/types';
 import { {% for name in repoImports %}
   {{ name }}{% if not loop.last %}, {% endif %}
 {%- endfor %}
diff --git a/src/php/Resources/views/resources.ts.twig b/src/php/Resources/views/resources.ts.twig
index a60203d1d252fb8a41a38d9f97d7b254758eb473..5f98c8b91cc714dd03356f4821a58bb4bf991a42 100644
--- a/src/php/Resources/views/resources.ts.twig
+++ b/src/php/Resources/views/resources.ts.twig
@@ -3,7 +3,7 @@
 {% block content %}
 {% autoescape false %}
 // @ts-ignore
-import { DateTime, IRI, UUID } from './common';
+import { DateTime, IRI, UUID } from 'irstea-ng-model/types';
 
 /*********************************************************************************
  * Ressources
diff --git a/src/ts/cache.service.spec.ts b/src/ts/cache.service.spec.ts
deleted file mode 100644
index 4b23a8bf8ba35294bf0616ea73d69a574207d70f..0000000000000000000000000000000000000000
--- a/src/ts/cache.service.spec.ts
+++ /dev/null
@@ -1,374 +0,0 @@
-import { HttpHeaderResponse } from '@angular/common/http';
-import { inject, TestBed } from '@angular/core/testing';
-import * as _ from 'lodash';
-import { forkJoin, Observable } from 'rxjs';
-import { catchError, map, mergeMap } from 'rxjs/operators';
-
-import { MarbleTestScheduler } from '../../../testing/marbles';
-import { Collection, IRI, IRI_PROPERTY, Resource } from '../../shared/models';
-import { safeForkJoin } from '../../shared/rxjs';
-
-import { IRIMismatchError, MissingIRIError, ResourceCache, ValueHolder } from './cache.service';
-
-describe('cache.service', () => {
-  interface MyResource extends Resource {
-    readonly '@id': IRI<MyResource>;
-    readonly '@type': 'MyResource';
-    value: string;
-    value2?: string;
-  }
-
-  function iri(x: string): IRI<MyResource> {
-    return x as any;
-  }
-
-  const MY_IRI = iri('/bla/a');
-  const OTHER_IRI = iri('/bla/B');
-  const VALUES: { [name: string]: MyResource } = {
-    a: { '@id': MY_IRI, '@type': 'MyResource', value: 'foo' },
-    b: { '@id': MY_IRI, '@type': 'MyResource', value: 'bar' },
-    c: { '@id': MY_IRI, '@type': 'MyResource', value: 'bar', value2: 'quz' },
-    d: { '@id': OTHER_IRI, '@type': 'MyResource', value: 'zig' },
-  };
-  let scheduler: MarbleTestScheduler<any>;
-  beforeEach(() => {
-    scheduler = MarbleTestScheduler.create(VALUES, 'error');
-  });
-
-  describe('ValueHolder', () => {
-    let holder: ValueHolder<any>;
-
-    beforeEach(() => {
-      holder = new ValueHolder<MyResource>(iri('/bla/a'));
-    });
-
-    it('should be created', () => {
-      expect(holder).toBeTruthy();
-    });
-
-    describe('.set()', () => {
-      function testSet({ value, error, SET_M }: any) {
-        scheduler.withError(error).run(({ expectObservable }) => {
-          expectObservable(holder.set(value)).toBe(SET_M);
-        });
-      }
-
-      it('should provide the value', () =>
-        testSet({
-          value: VALUES.a,
-          SET_M: '(a|)',
-        }));
-
-      it(`should refuse value without ${IRI_PROPERTY}`, () =>
-        testSet({
-          value: {},
-          error: new MissingIRIError(),
-          SET_M: '#',
-        }));
-
-      it('should refuse value with different @id', () =>
-        testSet({
-          value: { '@id': iri('bar') },
-          error: new IRIMismatchError(MY_IRI, iri('bar')),
-          SET_M: '#',
-        }));
-
-      it('should always points to the same instance', () => {
-        forkJoin(holder.set(VALUES.a), holder.set(VALUES.b)).subscribe(([a, b]: MyResource[]) => {
-          expect(a).toBe(b);
-        });
-      });
-    });
-
-    describe('.update()', () => {
-      it('should provide the value from the server', () =>
-        scheduler.run(({ cold, expectObservable }) => {
-          const REQ_M = '---a|';
-          const UPD_M = '--(a|) ';
-
-          const request$ = cold(REQ_M);
-          expectObservable(holder.update(request$)).toBe(UPD_M);
-        }));
-
-      it('should cancel pending requests', () => {
-        const LOCAL_VALUES = {
-          a: VALUES.a,
-          b: VALUES.b,
-          j: [VALUES.b, VALUES.b],
-        };
-        const REQ1_M = '---a|';
-        const REQ2_M = 'b|   ';
-
-        const UPDA_M = '(j|) ';
-        const REQ1_S = '(^!) ';
-        const REQ2_S = '(^!) ';
-
-        scheduler.withValues(LOCAL_VALUES).run(({ cold, expectObservable, expectSubscriptions }) => {
-          const request1$ = cold(REQ1_M);
-          const request2$ = cold(REQ2_M);
-
-          expectObservable(
-            safeForkJoin([
-              //
-              holder.update(request1$),
-              holder.update(request2$),
-            ])
-          ).toBe(UPDA_M);
-          expectSubscriptions(request1$.subscriptions).toBe(REQ1_S);
-          expectSubscriptions(request2$.subscriptions).toBe(REQ2_S);
-        });
-      });
-
-      it('should propagate errors', () =>
-        scheduler.run(({ cold, expectObservable }) => {
-          const REQ_M = '#';
-          const UPD_M = '#';
-
-          const request$ = cold(REQ_M);
-          expectObservable(holder.update(request$)).toBe(UPD_M);
-        }));
-
-      it('should restart on errors', () =>
-        scheduler.run(({ cold, expectObservable }) => {
-          const REQ1_M = '#';
-          const UPD1_M = '#';
-
-          const obs$ = holder.update(cold(REQ1_M));
-          expectObservable(obs$).toBe(UPD1_M);
-
-          const REQ2_M = '-a|';
-          const UPD2_M = '-a|';
-
-          const obs2$ = holder.update(cold(REQ2_M));
-          expectObservable(obs2$).toBe(UPD2_M);
-        }));
-    });
-
-    describe('.listen()', () => {
-      function testListen({ REQUEST_M, LISTEN_M, initial }: any) {
-        scheduler.run(({ cold, expectObservable }) => {
-          if (initial) {
-            holder.set(initial);
-          }
-          expectObservable(holder.listen(() => cold(REQUEST_M))).toBe(LISTEN_M);
-        });
-      }
-
-      it('should provide the value', () =>
-        testListen({
-          initial: VALUES.a,
-          REQUEST_M: /**/ '    ',
-          LISTEN_M: /***/ '(a|)',
-        }));
-
-      it('should cache the value', () =>
-        testListen({
-          initial: VALUES.a,
-          REQUEST_M: /**/ 'b|  ',
-          LISTEN_M: /***/ '(a|)',
-        }));
-
-      it('should propagate errors', () =>
-        testListen({
-          REQUEST_M: /**/ '#',
-          LISTEN_M: /***/ '#',
-        }));
-    });
-
-    it('.invalidate() should cause the value to be requested again', () =>
-      scheduler.run(({ cold, expectObservable }) => {
-        const REQUEST_M = /**/ '(a|)';
-        const LISTEN_M = /***/ '(a|)';
-        const requestFactory = jasmine.createSpy('requestFactory');
-        requestFactory.and.returnValue(cold(REQUEST_M));
-
-        return holder
-          .set(VALUES.a)
-          .toPromise()
-          .then(() => holder.invalidate())
-          .then(() => expectObservable(holder.listen(requestFactory)).toBe(LISTEN_M))
-          .then(() => expect(requestFactory).toHaveBeenCalled());
-      }));
-  });
-
-  describe('ResourceCache', () => {
-    beforeEach(() =>
-      TestBed.configureTestingModule({
-        providers: [ResourceCache],
-      })
-    );
-
-    it('should be created', inject([ResourceCache], (service: ResourceCache) => {
-      expect(service).toBeDefined();
-    }));
-
-    describe('.get()', () => {
-      it('should provide the value', inject([ResourceCache], (service: ResourceCache) =>
-        scheduler.run(({ cold, expectObservable }) => {
-          const getQuery$ = cold('a|');
-
-          expectObservable(service.get(MY_IRI, () => getQuery$)).toBe('(a|)');
-        })
-      ));
-
-      it('should cache the value', inject([ResourceCache], (service: ResourceCache) =>
-        scheduler.run(({ cold, expectObservable }) => {
-          const getQuery$ = cold('a|');
-          const getQuery2$ = cold('b|');
-          // tslint:disable-next-line:rxjs-finnish
-          const queries$ = cold('ab|', { a: getQuery$, b: getQuery2$ });
-
-          expectObservable(queries$.pipe(mergeMap(query$ => service.get(MY_IRI, () => query$)))).toBe('aa|');
-        })
-      ));
-
-      it('should propagate errors', inject([ResourceCache], (service: ResourceCache) =>
-        scheduler.run(({ cold, expectObservable }) => {
-          const getQuery$ = cold('#');
-
-          expectObservable(service.get(MY_IRI, () => getQuery$)).toBe('#');
-        })
-      ));
-    });
-
-    describe('.put()', () => {
-      it('should provide the value', inject([ResourceCache], (service: ResourceCache) => {
-        const putRequest$ = scheduler.createColdObservable('a|');
-
-        scheduler.expectObservable(service.put(MY_IRI, putRequest$)).toBe('a|');
-      }));
-
-      it('should not cache the value', inject([ResourceCache], (service: ResourceCache) =>
-        scheduler.run(({ cold, expectObservable }) => {
-          const putRequest$ = cold('a|');
-          const putRequest2$ = cold('b|');
-          // tslint:disable-next-line:rxjs-finnish
-          const requests$ = cold('ab|', { a: putRequest$, b: putRequest2$ });
-
-          expectObservable(
-            requests$.pipe(
-              mergeMap((request$: Observable<MyResource>) => service.put(MY_IRI, request$)),
-              map(x => _.clone(x))
-            )
-          ).toBe('ab|');
-        })
-      ));
-
-      it('should propagate errors', inject([ResourceCache], (service: ResourceCache) =>
-        scheduler.run(({ cold, expectObservable }) => {
-          const putRequest$ = cold('#');
-
-          expectObservable(service.put(MY_IRI, putRequest$)).toBe('#');
-        })
-      ));
-    });
-
-    describe('.post()', () => {
-      it('should provide the value', inject([ResourceCache], (service: ResourceCache) =>
-        scheduler.run(({ cold, expectObservable }) => {
-          const postRequest$ = cold('a|');
-
-          expectObservable(service.post(postRequest$)).toBe('a|');
-        })
-      ));
-
-      it('should propagate errors', inject([ResourceCache], (service: ResourceCache) =>
-        scheduler.run(({ cold, expectObservable }) => {
-          const postRequest$ = cold('#');
-
-          expectObservable(service.post(postRequest$)).toBe('#');
-        })
-      ));
-    });
-
-    describe('.delete()', () => {
-      it('should clear the cache on successful fetch', inject([ResourceCache], (service: ResourceCache) => {
-        const response = new HttpHeaderResponse({ status: 200 });
-        const values = { r: response, a: VALUES.a, b: VALUES.b };
-        const sched = scheduler.withValues(values);
-
-        sched.run(async ({ cold, expectObservable }) => {
-          await service.get(MY_IRI, () => cold('a|')).toPromise();
-          await service.delete(MY_IRI, cold('r|')).toPromise();
-
-          return expectObservable(service.get(MY_IRI, () => cold('b|'))).toBe('(b|)');
-        });
-      }));
-
-      it('should propagate errors', inject([ResourceCache], (service: ResourceCache) =>
-        scheduler.run(({ cold, expectObservable }) => {
-          const deleteRequest$ = cold('#');
-
-          expectObservable(service.delete(MY_IRI, deleteRequest$)).toBe('#');
-        })
-      ));
-
-      it('should not clear the cache on error', inject([ResourceCache], (service: ResourceCache) => {
-        const error = new HttpHeaderResponse({ status: 500 });
-        const values = { a: VALUES.a, b: VALUES.b, e: error };
-        scheduler
-          .withValues(values)
-          .withError(error)
-          .run(async ({ cold, expectObservable }) => {
-            await service.get(MY_IRI, () => cold('a|')).toPromise();
-            await service
-              .delete(MY_IRI, cold('#'))
-              .pipe(catchError(e => e))
-              .toPromise();
-
-            return expectObservable(service.get(MY_IRI, () => cold('b|'))).toBe('(a|)');
-          });
-      }));
-    });
-
-    describe('.getAll()', () => {
-      it('should provide the returned value', inject([ResourceCache], (service: ResourceCache) => {
-        const values = {
-          a: { 'hydra:member': [VALUES.a, VALUES.d], 'hydra:totalItems': 2 },
-        };
-        scheduler.withValues(values).run(({ cold, expectObservable }) => {
-          const getAllRequest$ = cold('a|');
-          expectObservable(service.getAll(getAllRequest$)).toBe('a|');
-        });
-      }));
-
-      it('should nicely handle empty collections', inject([ResourceCache], (service: ResourceCache) => {
-        const values = {
-          a: { 'hydra:member': [] as any, 'hydra:totalItems': 0 },
-        };
-        scheduler.withValues(values).run(({ cold, expectObservable }) => {
-          const getAllRequest$ = cold('a|');
-          expectObservable(service.getAll(getAllRequest$)).toBe('a|');
-        });
-      }));
-
-      it('should propagate errors', inject([ResourceCache], (service: ResourceCache) =>
-        scheduler.run(({ cold, expectObservable }) => {
-          const getAllRequest$ = cold<Collection<MyResource>>('#');
-          expectObservable(service.getAll(getAllRequest$)).toBe('#');
-        })
-      ));
-
-      it('should populate the cache', inject([ResourceCache], (service: ResourceCache) => {
-        const values = {
-          a: VALUES.a,
-          b: VALUES.d,
-          h: {
-            'hydra:member': [VALUES.a, VALUES.d],
-            'hydra:totalItems': 2,
-          },
-        };
-        scheduler.withValues(values).run(({ cold, expectObservable }) => {
-          const getAllRequest$ = cold('h|');
-          const getRequest$ = cold('b|');
-          const requests$ = cold<() => Observable<any>>('ab|', {
-            a: () => service.getAll(getAllRequest$),
-            b: () => service.get(MY_IRI, () => getRequest$),
-          });
-
-          expectObservable(requests$.pipe(mergeMap((sendRequest: () => Observable<any>) => sendRequest()))).toBe('ha|');
-        });
-      }));
-    });
-  });
-});
diff --git a/src/ts/cache.service.ts b/src/ts/cache.service.ts
deleted file mode 100644
index 1ed095f362209e42fe751692014de22392e2b468..0000000000000000000000000000000000000000
--- a/src/ts/cache.service.ts
+++ /dev/null
@@ -1,178 +0,0 @@
-import { HttpResponseBase } from '@angular/common/http';
-import * as _ from 'lodash';
-import { Observable, of, race, Subject, throwError } from 'rxjs';
-import { map, switchMap, take, tap } from 'rxjs/operators';
-
-import {
-  AbstractResourceCache,
-  Collection,
-  COLLECTION_MEMBERS,
-  getCollectionMembers,
-  IRI,
-  IRI_PROPERTY,
-  Resource,
-} from '../../shared/models';
-import { safeForkJoin } from '../../shared/rxjs';
-
-export class APICacheError extends Error {}
-
-export class MissingIRIError extends APICacheError {
-  public constructor() {
-    super(`resource must have an ${IRI_PROPERTY} property`);
-  }
-}
-
-export class IRIMismatchError extends APICacheError {
-  public constructor(expected: any, actual: any) {
-    super(`${IRI_PROPERTY}s mismatch: ${actual} !== ${expected}`);
-  }
-}
-
-/**
- * ValueHolder gère les requêtes d'une seule ressource.
- *
- * @internal
- */
-export class ValueHolder<R extends Resource> {
-  private readonly value$ = new Subject<R>();
-
-  private readonly value = {} as R;
-  private version = 0;
-
-  constructor(private readonly iri: IRI<R>) {}
-
-  public set(value: R): Observable<R> {
-    if (!(IRI_PROPERTY in value)) {
-      return throwError(new MissingIRIError());
-    }
-    if (value[IRI_PROPERTY] !== this.iri) {
-      return throwError(new IRIMismatchError(this.iri, value[IRI_PROPERTY]));
-    }
-
-    _.assign(this.value, value);
-    _(this.value)
-      .keys()
-      .difference(_.keys(value))
-      .forEach(key => delete this.value[key]);
-    this.version++;
-    this.value$.next(this.value);
-
-    return of(this.value);
-  }
-
-  public listen(queryFactory: () => Observable<R>): Observable<R> {
-    if (this.version > 0) {
-      return of(this.value);
-    }
-    return this.update(queryFactory());
-  }
-
-  public update(request$: Observable<R>): Observable<R> {
-    return race(this.value$.pipe(take(1)), request$.pipe(switchMap((item: R) => this.set(item))));
-  }
-
-  public invalidate(): void {
-    this.version = 0;
-  }
-
-  public delete(): void {
-    this.value$.complete();
-  }
-}
-
-/**
- * Implémentation d'un cache de resource.
- *
- * Cette implémentation met en place une queue de requête ainsi qu'un observable pour chaque ressource.
- *
- * La queue de requête permet de mettre à jour une ressource en cache. switchMap est utilisé pour prendre en compte
- * les valeurs des dernières requêtes.
- *
- * L'Observable s'assure de retourner toujours la même référence d'objet tout au long de la vie
- * de la ressource dans le cache, il permet aussi de faire suivre tout mise à jour à d'eventuels subscribers.
- */
-export class ResourceCache extends AbstractResourceCache {
-  private readonly holders = new Map<IRI<Resource>, ValueHolder<Resource>>();
-
-  /**
-   * Retourne la ressource identifiée par l'IRI donné.
-   *
-   * Effectue une requête si on le connait pas.
-   */
-  public get<R extends Resource>(iri: IRI<R>, requestFactory: () => Observable<R>): Observable<R> {
-    return this.getHolder(iri).listen(requestFactory);
-  }
-
-  /**
-   * Envoie une requête de mise à jour puis met à jour le cache avec la réponse du serveur.
-   */
-  public put<R extends Resource>(iri: IRI<R>, query$: Observable<R>): Observable<R> {
-    return this.getHolder(iri).update(query$);
-  }
-
-  /**
-   * Envoie une requête de création puis met à jour le chache avec la réponse.
-   */
-  public post<R extends Resource>(query$: Observable<R>): Observable<R> {
-    return query$.pipe(switchMap(item => this.received(item)));
-  }
-
-  /**
-   * Supprime une ressource sur le serveur puis en cache.
-   */
-  public delete<R extends Resource>(iri: IRI<R>, query$: Observable<HttpResponseBase>): Observable<HttpResponseBase> {
-    return query$.pipe(
-      tap(() => {
-        if (!this.holders.has(iri)) {
-          return;
-        }
-        this.holders.get(iri).delete();
-        this.holders.delete(iri);
-      })
-    );
-  }
-
-  /**
-   * Fait une requête pour plusieurs ressources puis les mets en cache.
-   */
-  public getAll<R extends Resource>(query$: Observable<Collection<R>>): Observable<Collection<R>> {
-    return query$.pipe(
-      switchMap((coll: Collection<R>) => {
-        const members = getCollectionMembers(coll);
-        const memberObservables$ = members.map(item => this.received(item));
-        return safeForkJoin(memberObservables$).pipe(
-          map(items => Object.assign({} as Collection<R>, coll, { [COLLECTION_MEMBERS]: items }))
-        );
-      })
-    );
-  }
-
-  /**
-   * Invalide la valeur d'une IRI pour forcer une mise-à-jour.
-   */
-  public invalidate<R extends Resource>(iri: IRI<R>): void {
-    if (!this.holders.has(iri)) {
-      return;
-    }
-    this.holders.get(iri).invalidate();
-  }
-
-  /**
-   * Retourne le ValueHolder d'une IRI, ou le crée si nécessaire.
-   */
-  private getHolder<R extends Resource>(iri: IRI<R>): ValueHolder<R> {
-    let holder = this.holders.get(iri) as ValueHolder<R>;
-    if (!holder) {
-      holder = new ValueHolder<R>(iri);
-      this.holders.set(iri, holder);
-    }
-    return holder;
-  }
-
-  /**
-   * Retourne le ValueHolder d'une IRI, ou le crée si nécessaire.
-   */
-  private received<R extends Resource>(item: R): Observable<R> {
-    return this.getHolder(item[IRI_PROPERTY]).set(item);
-  }
-}
diff --git a/src/ts/cache/cache.service.spec.ts b/src/ts/cache/cache.service.spec.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d8002eacde182fe94a610407d5c4aa7fe75a5264
--- /dev/null
+++ b/src/ts/cache/cache.service.spec.ts
@@ -0,0 +1,378 @@
+import { HttpHeaderResponse } from '@angular/common/http';
+import { inject, TestBed } from '@angular/core/testing';
+import * as _ from 'lodash';
+import { forkJoin, Observable } from 'rxjs';
+import { catchError, map, mergeMap } from 'rxjs/operators';
+
+import { MarbleTestScheduler } from '../../../testing/marbles';
+import { Collection, IRI, IRI_PROPERTY, Resource } from '../../shared/models';
+import { safeForkJoin } from '../../shared/rxjs';
+
+import { IRIMismatchError, MissingIRIError, ResourceCache, ValueHolder } from './cache.service';
+
+describe('cache.service', () => {
+    interface MyResource extends Resource {
+        readonly '@id': IRI<MyResource>;
+        readonly '@type': 'MyResource';
+        value: string;
+        value2?: string;
+    }
+
+    function iri(x: string): IRI<MyResource> {
+        return x as any;
+    }
+
+    const MY_IRI = iri('/bla/a');
+    const OTHER_IRI = iri('/bla/B');
+    const VALUES: { [name: string]: MyResource } = {
+        a: { '@id': MY_IRI, '@type': 'MyResource', value: 'foo' },
+        b: { '@id': MY_IRI, '@type': 'MyResource', value: 'bar' },
+        c: { '@id': MY_IRI, '@type': 'MyResource', value: 'bar', value2: 'quz' },
+        d: { '@id': OTHER_IRI, '@type': 'MyResource', value: 'zig' },
+    };
+    let scheduler: MarbleTestScheduler<any>;
+    beforeEach(() => {
+        scheduler = MarbleTestScheduler.create(VALUES, 'error');
+    });
+
+    describe('ValueHolder', () => {
+        let holder: ValueHolder<any>;
+
+        beforeEach(() => {
+            holder = new ValueHolder<MyResource>(iri('/bla/a'));
+        });
+
+        it('should be created', () => {
+            expect(holder).toBeTruthy();
+        });
+
+        describe('.set()', () => {
+            function testSet({ value, error, SET_M }: any) {
+                scheduler.withError(error).run(({ expectObservable }) => {
+                    expectObservable(holder.set(value)).toBe(SET_M);
+                });
+            }
+
+            it('should provide the value', () =>
+                testSet({
+                    value: VALUES.a,
+                    SET_M: '(a|)',
+                }));
+
+            it(`should refuse value without ${IRI_PROPERTY}`, () =>
+                testSet({
+                    value: {},
+                    error: new MissingIRIError(),
+                    SET_M: '#',
+                }));
+
+            it('should refuse value with different @id', () =>
+                testSet({
+                    value: { '@id': iri('bar') },
+                    error: new IRIMismatchError(MY_IRI, iri('bar')),
+                    SET_M: '#',
+                }));
+
+            it('should always points to the same instance', () => {
+                forkJoin(holder.set(VALUES.a), holder.set(VALUES.b)).subscribe(([a, b]: MyResource[]) => {
+                    expect(a).toBe(b);
+                });
+            });
+        });
+
+        describe('.update()', () => {
+            it('should provide the value from the server', () =>
+                scheduler.run(({ cold, expectObservable }) => {
+                    const REQ_M = '---a|';
+                    const UPD_M = '--(a|) ';
+
+                    const request$ = cold(REQ_M);
+                    expectObservable(holder.update(request$)).toBe(UPD_M);
+                }));
+
+            it('should cancel pending requests', () => {
+                const LOCAL_VALUES = {
+                    a: VALUES.a,
+                    b: VALUES.b,
+                    j: [VALUES.b, VALUES.b],
+                };
+                const REQ1_M = '---a|';
+                const REQ2_M = 'b|   ';
+
+                const UPDA_M = '(j|) ';
+                const REQ1_S = '(^!) ';
+                const REQ2_S = '(^!) ';
+
+                scheduler.withValues(LOCAL_VALUES).run(({ cold, expectObservable, expectSubscriptions }) => {
+                    const request1$ = cold(REQ1_M);
+                    const request2$ = cold(REQ2_M);
+
+                    expectObservable(
+                        safeForkJoin([
+                            //
+                            holder.update(request1$),
+                            holder.update(request2$),
+                        ]),
+                    ).toBe(UPDA_M);
+                    expectSubscriptions(request1$.subscriptions).toBe(REQ1_S);
+                    expectSubscriptions(request2$.subscriptions).toBe(REQ2_S);
+                });
+            });
+
+            it('should propagate errors', () =>
+                scheduler.run(({ cold, expectObservable }) => {
+                    const REQ_M = '#';
+                    const UPD_M = '#';
+
+                    const request$ = cold(REQ_M);
+                    expectObservable(holder.update(request$)).toBe(UPD_M);
+                }));
+
+            it('should restart on errors', () =>
+                scheduler.run(({ cold, expectObservable }) => {
+                    const REQ1_M = '#';
+                    const UPD1_M = '#';
+
+                    const obs$ = holder.update(cold(REQ1_M));
+                    expectObservable(obs$).toBe(UPD1_M);
+
+                    const REQ2_M = '-a|';
+                    const UPD2_M = '-a|';
+
+                    const obs2$ = holder.update(cold(REQ2_M));
+                    expectObservable(obs2$).toBe(UPD2_M);
+                }));
+        });
+
+        describe('.listen()', () => {
+            function testListen({ REQUEST_M, LISTEN_M, initial }: any) {
+                scheduler.run(({ cold, expectObservable }) => {
+                    if (initial) {
+                        holder.set(initial);
+                    }
+                    expectObservable(holder.listen(() => cold(REQUEST_M))).toBe(LISTEN_M);
+                });
+            }
+
+            it('should provide the value', () =>
+                testListen({
+                    initial: VALUES.a,
+                    REQUEST_M: /**/ '    ',
+                    LISTEN_M: /***/ '(a|)',
+                }));
+
+            it('should cache the value', () =>
+                testListen({
+                    initial: VALUES.a,
+                    REQUEST_M: /**/ 'b|  ',
+                    LISTEN_M: /***/ '(a|)',
+                }));
+
+            it('should propagate errors', () =>
+                testListen({
+                    REQUEST_M: /**/ '#',
+                    LISTEN_M: /***/ '#',
+                }));
+        });
+
+        it('.invalidate() should cause the value to be requested again', () =>
+            scheduler.run(({ cold, expectObservable }) => {
+                const REQUEST_M = /**/ '(a|)';
+                const LISTEN_M = /***/ '(a|)';
+                const requestFactory = jasmine.createSpy('requestFactory');
+                requestFactory.and.returnValue(cold(REQUEST_M));
+
+                return holder
+                    .set(VALUES.a)
+                    .toPromise()
+                    .then(() => holder.invalidate())
+                    .then(() => expectObservable(holder.listen(requestFactory)).toBe(LISTEN_M))
+                    .then(() => expect(requestFactory).toHaveBeenCalled());
+            }));
+    });
+
+    describe('ResourceCache', () => {
+        beforeEach(() =>
+            TestBed.configureTestingModule({
+                providers: [ResourceCache],
+            }),
+        );
+
+        it('should be created', inject([ResourceCache], (service: ResourceCache) => {
+            expect(service).toBeDefined();
+        }));
+
+        describe('.get()', () => {
+            it('should provide the value', inject([ResourceCache], (service: ResourceCache) =>
+                scheduler.run(({ cold, expectObservable }) => {
+                    const getQuery$ = cold('a|');
+
+                    expectObservable(service.get(MY_IRI, () => getQuery$)).toBe('(a|)');
+                }),
+            ));
+
+            it('should cache the value', inject([ResourceCache], (service: ResourceCache) =>
+                scheduler.run(({ cold, expectObservable }) => {
+                    const getQuery$ = cold('a|');
+                    const getQuery2$ = cold('b|');
+                    // tslint:disable-next-line:rxjs-finnish
+                    const queries$ = cold('ab|', { a: getQuery$, b: getQuery2$ });
+
+                    expectObservable(queries$.pipe(mergeMap((query$) => service.get(MY_IRI, () => query$)))).toBe(
+                        'aa|',
+                    );
+                }),
+            ));
+
+            it('should propagate errors', inject([ResourceCache], (service: ResourceCache) =>
+                scheduler.run(({ cold, expectObservable }) => {
+                    const getQuery$ = cold('#');
+
+                    expectObservable(service.get(MY_IRI, () => getQuery$)).toBe('#');
+                }),
+            ));
+        });
+
+        describe('.put()', () => {
+            it('should provide the value', inject([ResourceCache], (service: ResourceCache) => {
+                const putRequest$ = scheduler.createColdObservable('a|');
+
+                scheduler.expectObservable(service.put(MY_IRI, putRequest$)).toBe('a|');
+            }));
+
+            it('should not cache the value', inject([ResourceCache], (service: ResourceCache) =>
+                scheduler.run(({ cold, expectObservable }) => {
+                    const putRequest$ = cold('a|');
+                    const putRequest2$ = cold('b|');
+                    // tslint:disable-next-line:rxjs-finnish
+                    const requests$ = cold('ab|', { a: putRequest$, b: putRequest2$ });
+
+                    expectObservable(
+                        requests$.pipe(
+                            mergeMap((request$: Observable<MyResource>) => service.put(MY_IRI, request$)),
+                            map((x) => _.clone(x)),
+                        ),
+                    ).toBe('ab|');
+                }),
+            ));
+
+            it('should propagate errors', inject([ResourceCache], (service: ResourceCache) =>
+                scheduler.run(({ cold, expectObservable }) => {
+                    const putRequest$ = cold('#');
+
+                    expectObservable(service.put(MY_IRI, putRequest$)).toBe('#');
+                }),
+            ));
+        });
+
+        describe('.post()', () => {
+            it('should provide the value', inject([ResourceCache], (service: ResourceCache) =>
+                scheduler.run(({ cold, expectObservable }) => {
+                    const postRequest$ = cold('a|');
+
+                    expectObservable(service.post(postRequest$)).toBe('a|');
+                }),
+            ));
+
+            it('should propagate errors', inject([ResourceCache], (service: ResourceCache) =>
+                scheduler.run(({ cold, expectObservable }) => {
+                    const postRequest$ = cold('#');
+
+                    expectObservable(service.post(postRequest$)).toBe('#');
+                }),
+            ));
+        });
+
+        describe('.delete()', () => {
+            it('should clear the cache on successful fetch', inject([ResourceCache], (service: ResourceCache) => {
+                const response = new HttpHeaderResponse({ status: 200 });
+                const values = { r: response, a: VALUES.a, b: VALUES.b };
+                const sched = scheduler.withValues(values);
+
+                sched.run(async ({ cold, expectObservable }) => {
+                    await service.get(MY_IRI, () => cold('a|')).toPromise();
+                    await service.delete(MY_IRI, cold('r|')).toPromise();
+
+                    return expectObservable(service.get(MY_IRI, () => cold('b|'))).toBe('(b|)');
+                });
+            }));
+
+            it('should propagate errors', inject([ResourceCache], (service: ResourceCache) =>
+                scheduler.run(({ cold, expectObservable }) => {
+                    const deleteRequest$ = cold('#');
+
+                    expectObservable(service.delete(MY_IRI, deleteRequest$)).toBe('#');
+                }),
+            ));
+
+            it('should not clear the cache on error', inject([ResourceCache], (service: ResourceCache) => {
+                const error = new HttpHeaderResponse({ status: 500 });
+                const values = { a: VALUES.a, b: VALUES.b, e: error };
+                scheduler
+                    .withValues(values)
+                    .withError(error)
+                    .run(async ({ cold, expectObservable }) => {
+                        await service.get(MY_IRI, () => cold('a|')).toPromise();
+                        await service
+                            .delete(MY_IRI, cold('#'))
+                            .pipe(catchError((e) => e))
+                            .toPromise();
+
+                        return expectObservable(service.get(MY_IRI, () => cold('b|'))).toBe('(a|)');
+                    });
+            }));
+        });
+
+        describe('.getAll()', () => {
+            it('should provide the returned value', inject([ResourceCache], (service: ResourceCache) => {
+                const values = {
+                    a: { 'hydra:member': [VALUES.a, VALUES.d], 'hydra:totalItems': 2 },
+                };
+                scheduler.withValues(values).run(({ cold, expectObservable }) => {
+                    const getAllRequest$ = cold('a|');
+                    expectObservable(service.getAll(getAllRequest$)).toBe('a|');
+                });
+            }));
+
+            it('should nicely handle empty collections', inject([ResourceCache], (service: ResourceCache) => {
+                const values = {
+                    a: { 'hydra:member': [] as any, 'hydra:totalItems': 0 },
+                };
+                scheduler.withValues(values).run(({ cold, expectObservable }) => {
+                    const getAllRequest$ = cold('a|');
+                    expectObservable(service.getAll(getAllRequest$)).toBe('a|');
+                });
+            }));
+
+            it('should propagate errors', inject([ResourceCache], (service: ResourceCache) =>
+                scheduler.run(({ cold, expectObservable }) => {
+                    const getAllRequest$ = cold<Collection<MyResource>>('#');
+                    expectObservable(service.getAll(getAllRequest$)).toBe('#');
+                }),
+            ));
+
+            it('should populate the cache', inject([ResourceCache], (service: ResourceCache) => {
+                const values = {
+                    a: VALUES.a,
+                    b: VALUES.d,
+                    h: {
+                        'hydra:member': [VALUES.a, VALUES.d],
+                        'hydra:totalItems': 2,
+                    },
+                };
+                scheduler.withValues(values).run(({ cold, expectObservable }) => {
+                    const getAllRequest$ = cold('h|');
+                    const getRequest$ = cold('b|');
+                    const requests$ = cold<() => Observable<any>>('ab|', {
+                        a: () => service.getAll(getAllRequest$),
+                        b: () => service.get(MY_IRI, () => getRequest$),
+                    });
+
+                    expectObservable(
+                        requests$.pipe(mergeMap((sendRequest: () => Observable<any>) => sendRequest())),
+                    ).toBe('ha|');
+                });
+            }));
+        });
+    });
+});
diff --git a/src/ts/cache/cache.service.ts b/src/ts/cache/cache.service.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3b3c7e9cd737f91a1cff86dc76064bac3349ac14
--- /dev/null
+++ b/src/ts/cache/cache.service.ts
@@ -0,0 +1,178 @@
+import { HttpResponseBase } from '@angular/common/http';
+import * as _ from 'lodash';
+import { Observable, of, race, Subject, throwError } from 'rxjs';
+import { map, switchMap, take, tap } from 'rxjs/operators';
+
+import {
+    AbstractResourceCache,
+    Collection,
+    COLLECTION_MEMBERS,
+    getCollectionMembers,
+    IRI,
+    IRI_PROPERTY,
+    Resource,
+} from '../../shared/models';
+import { safeForkJoin } from '../../shared/rxjs';
+
+export class APICacheError extends Error {}
+
+export class MissingIRIError extends APICacheError {
+    public constructor() {
+        super(`resource must have an ${IRI_PROPERTY} property`);
+    }
+}
+
+export class IRIMismatchError extends APICacheError {
+    public constructor(expected: any, actual: any) {
+        super(`${IRI_PROPERTY}s mismatch: ${actual} !== ${expected}`);
+    }
+}
+
+/**
+ * ValueHolder gère les requêtes d'une seule ressource.
+ *
+ * @internal
+ */
+export class ValueHolder<R extends Resource> {
+    private readonly value$ = new Subject<R>();
+
+    private readonly value = {} as R;
+    private version = 0;
+
+    constructor(private readonly iri: IRI<R>) {}
+
+    public set(value: R): Observable<R> {
+        if (!(IRI_PROPERTY in value)) {
+            return throwError(new MissingIRIError());
+        }
+        if (value[IRI_PROPERTY] !== this.iri) {
+            return throwError(new IRIMismatchError(this.iri, value[IRI_PROPERTY]));
+        }
+
+        _.assign(this.value, value);
+        _(this.value)
+            .keys()
+            .difference(_.keys(value))
+            .forEach((key) => delete this.value[key]);
+        this.version++;
+        this.value$.next(this.value);
+
+        return of(this.value);
+    }
+
+    public listen(queryFactory: () => Observable<R>): Observable<R> {
+        if (this.version > 0) {
+            return of(this.value);
+        }
+        return this.update(queryFactory());
+    }
+
+    public update(request$: Observable<R>): Observable<R> {
+        return race(this.value$.pipe(take(1)), request$.pipe(switchMap((item: R) => this.set(item))));
+    }
+
+    public invalidate(): void {
+        this.version = 0;
+    }
+
+    public delete(): void {
+        this.value$.complete();
+    }
+}
+
+/**
+ * Implémentation d'un cache de resource.
+ *
+ * Cette implémentation met en place une queue de requête ainsi qu'un observable pour chaque ressource.
+ *
+ * La queue de requête permet de mettre à jour une ressource en cache. switchMap est utilisé pour prendre en compte
+ * les valeurs des dernières requêtes.
+ *
+ * L'Observable s'assure de retourner toujours la même référence d'objet tout au long de la vie
+ * de la ressource dans le cache, il permet aussi de faire suivre tout mise à jour à d'eventuels subscribers.
+ */
+export class ResourceCache extends AbstractResourceCache {
+    private readonly holders = new Map<IRI<Resource>, ValueHolder<Resource>>();
+
+    /**
+     * Retourne la ressource identifiée par l'IRI donné.
+     *
+     * Effectue une requête si on le connait pas.
+     */
+    public get<R extends Resource>(iri: IRI<R>, requestFactory: () => Observable<R>): Observable<R> {
+        return this.getHolder(iri).listen(requestFactory);
+    }
+
+    /**
+     * Envoie une requête de mise à jour puis met à jour le cache avec la réponse du serveur.
+     */
+    public put<R extends Resource>(iri: IRI<R>, query$: Observable<R>): Observable<R> {
+        return this.getHolder(iri).update(query$);
+    }
+
+    /**
+     * Envoie une requête de création puis met à jour le chache avec la réponse.
+     */
+    public post<R extends Resource>(query$: Observable<R>): Observable<R> {
+        return query$.pipe(switchMap((item) => this.received(item)));
+    }
+
+    /**
+     * Supprime une ressource sur le serveur puis en cache.
+     */
+    public delete<R extends Resource>(iri: IRI<R>, query$: Observable<HttpResponseBase>): Observable<HttpResponseBase> {
+        return query$.pipe(
+            tap(() => {
+                if (!this.holders.has(iri)) {
+                    return;
+                }
+                this.holders.get(iri).delete();
+                this.holders.delete(iri);
+            }),
+        );
+    }
+
+    /**
+     * Fait une requête pour plusieurs ressources puis les mets en cache.
+     */
+    public getAll<R extends Resource>(query$: Observable<Collection<R>>): Observable<Collection<R>> {
+        return query$.pipe(
+            switchMap((coll: Collection<R>) => {
+                const members = getCollectionMembers(coll);
+                const memberObservables$ = members.map((item) => this.received(item));
+                return safeForkJoin(memberObservables$).pipe(
+                    map((items) => Object.assign({} as Collection<R>, coll, { [COLLECTION_MEMBERS]: items })),
+                );
+            }),
+        );
+    }
+
+    /**
+     * Invalide la valeur d'une IRI pour forcer une mise-à-jour.
+     */
+    public invalidate<R extends Resource>(iri: IRI<R>): void {
+        if (!this.holders.has(iri)) {
+            return;
+        }
+        this.holders.get(iri).invalidate();
+    }
+
+    /**
+     * Retourne le ValueHolder d'une IRI, ou le crée si nécessaire.
+     */
+    private getHolder<R extends Resource>(iri: IRI<R>): ValueHolder<R> {
+        let holder = this.holders.get(iri) as ValueHolder<R>;
+        if (!holder) {
+            holder = new ValueHolder<R>(iri);
+            this.holders.set(iri, holder);
+        }
+        return holder;
+    }
+
+    /**
+     * Retourne le ValueHolder d'une IRI, ou le crée si nécessaire.
+     */
+    private received<R extends Resource>(item: R): Observable<R> {
+        return this.getHolder(item[IRI_PROPERTY]).set(item);
+    }
+}
diff --git a/src/ts/types/api.ts b/src/ts/types/api.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7a2e7bb2698c4b073938e5ba08d77a6af03cd9f7
--- /dev/null
+++ b/src/ts/types/api.ts
@@ -0,0 +1,173 @@
+import { forkJoin, Observable } from 'rxjs';
+import { HttpClient, HttpHeaders } from '@angular/common/http';
+import { IRI, Resource } from './resource';
+import { IRIParameters, ResourceMetadata } from './metadata';
+import { AbstractRepository } from './repositories';
+import { AbstractResourceCache } from './cache';
+
+/**
+ * Options supplémentaires pour les requêtes.
+ */
+export interface RequestOptions {
+    body?: any;
+    headers?:
+        | HttpHeaders
+        | {
+              [header: string]: string | string[];
+          };
+    params?: { [param: string]: string | string[] };
+}
+
+/**
+ * Sur-type encadrant des API.
+ */
+export interface APIMeta {
+    [type: string]: {
+        resource: Resource;
+        metadata: ResourceMetadata<any, any, IRIParameters>;
+        repository: AbstractRepository<any, any, IRIParameters>;
+        iriParameters: IRIParameters;
+    };
+}
+
+/**
+ * Classe abstraite d'un registre des metadonnées des ressources.
+ */
+export interface APIMetadataRegistry<API extends APIMeta> {
+    /**
+     * Vérifie si on a des métadonnées pour le type de ressourcce indiqué.
+     */
+    has<T extends keyof API>(type: T): type is T;
+
+    /**
+     * (Construit et) retourne l'instance de métadonnées pour le type de resource 'type'.
+     */
+    get<T extends keyof API>(type: T): API[T]['metadata'];
+}
+
+/**
+ * Registre de métadonnées qui construit les instances à la demande (ce qui permet de gérer les
+ * dépendances entre métadonnées).
+ */
+export class LazyMetadataRegistry<API extends APIMeta> implements APIMetadataRegistry<API> {
+    private readonly instances: { [T in keyof API]?: API[T]['metadata'] } = {};
+
+    protected constructor(
+        private readonly builders: { readonly [T in keyof API]: (r?: APIMetadataRegistry<API>) => API[T]['metadata'] },
+    ) {}
+
+    public has<T extends keyof API>(type: T): type is T {
+        return typeof type === 'string' && type in this.builders;
+    }
+
+    public get<T extends keyof API>(type: T): API[T]['metadata'] {
+        if (!this.has(type)) {
+            throw new Error(`Invalid resource type: ${type}`);
+        }
+        return this.getOrCreate(type);
+    }
+
+    protected getOrCreate<T extends keyof API>(type: T): API[T]['metadata'] {
+        if (!(type in this.instances)) {
+            this.instances[type] = this.builders[type](this);
+        }
+        return this.instances[type] as API[T]['metadata'];
+    }
+}
+
+/**
+ * Classe abstraite de la façade de l'API.
+ */
+export interface APIRepositoryRegistry<API extends APIMeta> {
+    get<T extends keyof API>(type: T): API[T]['repository'];
+}
+
+/**
+ * Service permettant d'accéder à l'API.
+ */
+export interface APIService<API extends APIMeta> {
+    /**
+     * Métadonnées de l'API.
+     */
+    readonly metadata: APIMetadataRegistry<API>;
+
+    /**
+     * Repositories de l'API
+     */
+    readonly repositories: APIRepositoryRegistry<API>;
+
+    /**
+     * Récupère une ressource par son IRI ou par son type et les paramètres de son IRI.
+     */
+    get<R extends Resource>(iri: IRI<R>, options?: RequestOptions): Observable<R>;
+    get<T extends keyof API>(
+        type: T,
+        parameters: API[T]['iriParameters'],
+        options?: RequestOptions,
+    ): Observable<API[T]['resource']>;
+
+    /**
+     * Récupère des ressources par leurs IRIs.
+     */
+    getMany<R extends Resource>(iris: IRI<R>[], options?: RequestOptions): Observable<R[]>;
+
+    /**
+     * Génère l'IRI d'une resource à partir de son type et des paramètres d'IRI.
+     */
+    generateIRI<T extends keyof API, P extends string[]>(type: T, parameters: P): IRI<any>;
+
+    /**
+     * Invalide le cache pour une IRI.
+     */
+    invalidate<R extends Resource>(iri: IRI<R>): void;
+}
+
+/**
+ * Implémentation de base d'une api
+ */
+export abstract class AbstractAPIService<
+    API extends APIMeta,
+    MR extends APIMetadataRegistry<API>,
+    RR extends APIRepositoryRegistry<API>
+> implements APIService<API> {
+    public constructor(
+        public readonly metadata: MR,
+        public readonly repositories: RR,
+        private readonly cache: AbstractResourceCache,
+        private readonly client: HttpClient,
+    ) {}
+
+    public get<R extends Resource>(iri: IRI<R>, options?: RequestOptions): Observable<R>;
+    public get<T extends keyof API>(
+        type: T,
+        parameters: API[T]['iriParameters'],
+        options?: RequestOptions,
+    ): Observable<API[T]['resource']>;
+
+    public get<T extends keyof API, R extends Resource = API[T]['resource']>(
+        typeOrIRI: T | IRI<R>,
+        parametersOrOptions?: API[T]['iriParameters'] | RequestOptions,
+        options?: RequestOptions,
+    ): Observable<R> {
+        let iri: IRI<R>;
+        if (this.metadata.has(typeOrIRI as string)) {
+            iri = this.metadata.get(typeOrIRI as string).generateIRI(parametersOrOptions as API[T]['iriParameters']);
+        } else {
+            iri = typeOrIRI as IRI<R>;
+            options = parametersOrOptions as RequestOptions;
+        }
+        return this.cache.get(iri, () => this.client.get<R>(iri as any, options));
+    }
+
+    public getMany<R extends Resource>(iris: IRI<R>[], options?: RequestOptions): Observable<R[]> {
+        return forkJoin(iris.map((iri) => this.get(iri, options)));
+    }
+
+    public generateIRI<T extends keyof API>(type: T, parameters: API[T]['iriParameters']): IRI<API[T]['resource']> {
+        return this.metadata.get(type).generateIRI(parameters);
+    }
+
+    public invalidate<R extends Resource>(iri: IRI<R>): void {
+        this.cache.invalidate(iri);
+    }
+}
diff --git a/src/ts/types/cache.ts b/src/ts/types/cache.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8ec31f72111284f5af6c0d48e80e9e25a3ba7276
--- /dev/null
+++ b/src/ts/types/cache.ts
@@ -0,0 +1,54 @@
+import { IRI, Resource } from './resource';
+import { Observable } from 'rxjs';
+import { HttpResponseBase } from '@angular/common/http';
+import { Collection } from './collection';
+
+/**
+ * AbstractResourceCache est une classe abstraite qui définit l'interface d'un cache de ressources.
+ *
+ * Elle doit être implémentée puis être fournie en provider d'un module core. Par exemple:
+ *
+ *      final class ResourceCache<T extends Resource> extends AbstractResourceCache<T> {
+ *        // Implémentation
+ *      }
+ *
+ *      providers: [
+ *        [ provider: AbstractResourceCache, useClass: ResourceCache ],
+ *      ]
+ */
+
+export abstract class AbstractResourceCache {
+    /**
+     * Récupère une ressource par son IRI. N'exécute la requête requestFactory que si on ne dispose
+     * pas d'une version en cache.
+     */
+    public abstract get<R extends Resource>(iri: IRI<R>, requestFactory: () => Observable<R>): Observable<R>;
+
+    /**
+     * Met à jour une ressource existante, rafraîchit le cache local avec la réponse.
+     */
+    public abstract put<R extends Resource>(iri: IRI<R>, request: Observable<R>): Observable<R>;
+
+    /**
+     * Crée une nouvelle ressource et met la ressource créée dans le cache.
+     */
+    public abstract post<R extends Resource>(request: Observable<R>): Observable<R>;
+
+    /**
+     * Supprime une ressource en distant et dans le cache.
+     */
+    public abstract delete<R extends Resource>(
+        iri: IRI<R>,
+        request: Observable<HttpResponseBase>,
+    ): Observable<HttpResponseBase>;
+
+    /**
+     * Effectue une recherche et met en cache toutes les ressources récupérées.
+     */
+    public abstract getAll<R extends Resource>(request: Observable<Collection<R>>): Observable<Collection<R>>;
+
+    /**
+     * Invalide une ressource en cache.
+     */
+    public abstract invalidate<R extends Resource>(iri: IRI<R>): void;
+}
diff --git a/src/ts/types/collection.ts b/src/ts/types/collection.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f6a34e0c99e0b643859d7c500cf84ce9e05556a0
--- /dev/null
+++ b/src/ts/types/collection.ts
@@ -0,0 +1,35 @@
+import { Resource, JSONValue } from './resource';
+
+/**
+ * Nom de la propriété contenant les resource d'une collection.
+ */
+export const COLLECTION_MEMBERS = 'hydra:member';
+
+/**
+ * Nom de la propriété contenant le nombre total d'objet d'une collection.
+ */
+export const COLLECTION_TOTAL_COUNT = 'hydra:totalItems';
+
+/**
+ * Collection représente une collection de respoucres JSON-LD pour un type T donné.
+ */
+export interface Collection<R extends Resource> {
+    [COLLECTION_MEMBERS]: R[];
+    [COLLECTION_TOTAL_COUNT]: number;
+    [property: string]: JSONValue;
+}
+
+/**
+ * Retourne les membres d'une collection.
+ */
+export function getCollectionMembers<R extends Resource>(collection: Collection<R>): R[] {
+    return collection[COLLECTION_MEMBERS];
+}
+
+/**
+ * Retourne le nombre total d'items
+ * @param collection
+ */
+export function getCollectionTotalCount<R extends Resource>(collection: Collection<R>): number {
+    return collection[COLLECTION_TOTAL_COUNT];
+}
diff --git a/src/ts/types/index.ts b/src/ts/types/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2a4d5409a17580928849d57d0c5743f6310d7a72
--- /dev/null
+++ b/src/ts/types/index.ts
@@ -0,0 +1,3 @@
+export * from './resource';
+export * from './collection';
+export * from './misc';
diff --git a/src/ts/types/interfaces.ts b/src/ts/types/interfaces.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0f88283a381095e4cc1eb7432e07fa853db70fd8
--- /dev/null
+++ b/src/ts/types/interfaces.ts
@@ -0,0 +1,3 @@
+import { JSONValue, Resource } from './resource';
+import { IRIParameters, ResourceMetadata } from './metadata';
+import { AbstractRepository } from './common';
diff --git a/src/ts/types/metadata.ts b/src/ts/types/metadata.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ffd833c06b726e9bec6f9209bfb083ecccf5e763
--- /dev/null
+++ b/src/ts/types/metadata.ts
@@ -0,0 +1,102 @@
+/**
+ * Type des paramètrès
+ */
+import { IRI, Resource, TYPE_PROPERTY } from './resource';
+
+type IRIParameter = string | number;
+export type IRIParameters = IRIParameter[] | IRIParameter;
+
+/**
+ * Informations sur l'IRI d'une resource.
+ *
+ * P: types des paramètres.
+ */
+export class IRIMetadata<P extends IRIParameters> {
+    public constructor(
+        private readonly testPattern: RegExp,
+        private readonly capturePattern: RegExp,
+        private readonly template: (parameters: P) => string,
+    ) {}
+
+    public validate(path: string): boolean {
+        return this.testPattern.test(path);
+    }
+
+    public generate(parameters: P): IRI<any> {
+        return this.template(parameters) as any;
+    }
+
+    public parse(path: string): P {
+        const matches = this.capturePattern.exec(path);
+        if (!matches) {
+            throw new Error(`Invalid path: ${path} does not match ${this.capturePattern}`);
+        }
+        if (matches.length == 2) {
+            return matches[1] as any;
+        }
+        return matches.slice(1) as any;
+    }
+}
+
+function hasTypeProperty(that: unknown): that is { [TYPE_PROPERTY]: unknown } {
+    return typeof that === 'object' && that !== null && TYPE_PROPERTY in that;
+}
+
+function getResourceType(that: unknown): string | null {
+    if (hasTypeProperty(that)) {
+        const type = that[TYPE_PROPERTY];
+        if (typeof type === 'string') {
+            return type;
+        }
+    }
+    return null;
+}
+
+/**
+ * Metadonnées d'une ressource.
+ *
+ * R : type de resource, e.g. Person
+ * T : valeur de la propriété '@type', e.g. 'Person'.
+ */
+export class ResourceMetadata<R extends Resource, T extends string, P extends IRIParameters> {
+    public constructor(
+        public readonly type: T,
+        public readonly iri: IRIMetadata<P>,
+        private readonly requiredProperties: (keyof R)[],
+        private readonly types: ResourceMetadata<any, any, any>[] = [],
+    ) {
+        this.types.unshift(this);
+    }
+
+    /**
+     * Vérifie si l'argument représente une ressource R ou dérivée.
+     */
+    public isResource(that: unknown): that is R {
+        const type = getResourceType(that);
+        if (!type) {
+            return false;
+        }
+        return this.types.some((t) => t.type === type);
+    }
+
+    /**
+     * Vérifie si une propriété est obligatoire dans la resource R.
+     */
+    public isRequired(property: keyof R): boolean {
+        return this.requiredProperties.includes(property);
+    }
+
+    /**
+     * Génère une IRI à partir de ses paramètres.
+     */
+    public generateIRI(parameters: P): IRI<R> {
+        return this.iri.generate(parameters);
+    }
+
+    /**
+     * Extrait les paramètres d'une IRI.
+     */
+    public getIRIParameters(iri: IRI<R>): P {
+        return this.iri.parse(iri as any);
+    }
+}
diff --git a/src/ts/types/misc.spec.ts b/src/ts/types/misc.spec.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a5a700ffcf3fde5ef6a361f38f9207596bb0b21c
--- /dev/null
+++ b/src/ts/types/misc.spec.ts
@@ -0,0 +1,35 @@
+import { isDateTime } from './misc';
+
+describe('isDateTime', () => {
+    const itShouldRejectType = (value: unknown) =>
+        it(`should reject ${JSON.stringify(value)}`, () => expect(isDateTime(value)).toBeFalsy());
+
+    itShouldRejectType(false);
+    itShouldRejectType(null);
+    itShouldRejectType(undefined);
+    itShouldRejectType([]);
+    itShouldRejectType({ value: 'foo' });
+
+    const itShouldReject = (value: string) => it(`should reject ${value}`, () => expect(isDateTime(value)).toBeFalsy());
+
+    itShouldReject('foobar');
+    itShouldReject('2000-01-32');
+    itShouldReject('2000-01-32T00:00');
+
+    itShouldReject('2000-01-32T00:00:00Z');
+    itShouldReject('2000-13-00T00:00:00Z');
+    itShouldReject('2000-26-00T00:00:00Z');
+    itShouldReject('2000-01-01T25:00:00Z');
+    itShouldReject('2000-01-01T35:00:00Z');
+    itShouldReject('2000-01-01T00:60:00Z');
+    itShouldReject('2000-01-01T00:00:72Z');
+    itShouldReject('2000-01-51T00:00:00Z');
+
+    const itShouldAccept = (value: string) =>
+        it(`should accept ${value}`, () => expect(isDateTime(value)).toBeTruthy());
+
+    for (let t = Date.parse('2000-01-01T00:00:00Z'); t < Date.parse('2001-01-01T00:00:00Z'); t += 86400000) {
+        const dateStr = new Date(t).toISOString();
+        itShouldAccept(dateStr);
+    }
+});
diff --git a/src/ts/types/misc.ts b/src/ts/types/misc.ts
new file mode 100644
index 0000000000000000000000000000000000000000..bd7fb16a75efb96b99662da62e07162bab727709
--- /dev/null
+++ b/src/ts/types/misc.ts
@@ -0,0 +1,24 @@
+/**
+ * Full DateTime in ISO-8601 format.
+ */
+export type DateTime = string;
+
+const DateTimeRegex = /^\d{4}-(?:0\d|1[012])-(?:3[01]|[012]\d)T(?:2[0-3]|[01]\d):[0-5]\d:[0-5]\d(?:\.\d+)?(?:[-+]\d{2}:\d{2}|Z)$/iu;
+
+export function isDateTime(data: unknown): data is DateTime {
+    return typeof data === 'string' && DateTimeRegex.test(data) && !Number.isNaN(Date.parse(data));
+}
+
+/**
+ * Universally Unique Identifier - RFC 4122
+ */
+export type UUID = string;
+
+const UUIDRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
+
+/**
+ * Teste si une donnée (chaîne) est formatée selon un UUID.
+ */
+export function isUUID(data: unknown): data is UUID {
+    return typeof data === 'string' && UUIDRegex.test(data);
+}
diff --git a/src/ts/types/repositories.ts b/src/ts/types/repositories.ts
new file mode 100644
index 0000000000000000000000000000000000000000..788d1bb78db6ee7f764343639ff7f660b4cf763a
--- /dev/null
+++ b/src/ts/types/repositories.ts
@@ -0,0 +1,29 @@
+import { HttpClient } from '@angular/common/http';
+import { IRI, Resource } from './resource';
+import { IRIParameters, ResourceMetadata } from './metadata';
+import { AbstractResourceCache } from './cache';
+
+/**
+ * Classe de base d'un repository.
+ */
+export abstract class AbstractRepository<R extends Resource, T extends string, P extends IRIParameters> {
+    public constructor(
+        public readonly metadata: ResourceMetadata<R, T, P>,
+        protected readonly client: HttpClient,
+        protected readonly cache: AbstractResourceCache,
+    ) {}
+
+    /**
+     * Génère une IRI à partir de ses paramètres.
+     */
+    public generateIRI(parameters: P): IRI<R> {
+        return this.metadata.generateIRI(parameters);
+    }
+
+    /**
+     * Extrait les paramètres d'une IRI.
+     */
+    public getIRIParameters(iri: IRI<R>): P {
+        return this.metadata.getIRIParameters(iri);
+    }
+}
diff --git a/src/ts/types/resource.ts b/src/ts/types/resource.ts
new file mode 100644
index 0000000000000000000000000000000000000000..5a56ed0715ba26bb426ec9440dc60292be857539
--- /dev/null
+++ b/src/ts/types/resource.ts
@@ -0,0 +1,43 @@
+/**
+ * Nom de la propriété d'une resource contenant son IRI.
+ */
+export const IRI_PROPERTY = '@id';
+
+/**
+ * Nom de la propriété d'une resource contenant son type.
+ */
+export const TYPE_PROPERTY = '@type';
+
+/**
+ * IRI typé.
+ *
+ * Internationalized Resource Identifier - RFC 3987
+ */
+const IRI = Symbol('IRI');
+
+/* Les IRI sont en fait des chaînes mais pour forcer un typage fort on les définit
+ * comme un type "opaque". De cette façon, il est impossible de mélanger
+ * IRI et chaînes, et le type générique R permet d'interdire les assignations entre
+ * IRI de resources différentes.
+ */
+export interface IRI<R extends Resource> {
+    readonly [IRI]?: R;
+}
+
+/**
+ * Resource
+ */
+export interface Resource {
+    readonly [IRI_PROPERTY]: IRI<any>;
+    readonly [TYPE_PROPERTY]: string;
+    [property: string]: JSONValue;
+}
+
+/**
+ * Sous-typage de valeurs JSON
+ */
+export type JSONValue = string | IRI<any> | number | null | JSONArray | JSONObject | Resource;
+export type JSONArray = Array<JSONValue>;
+export interface JSONObject {
+    [key: string]: JSONValue;
+}