diff --git a/.gitignore b/.gitignore
index afdcfdbb3b765d079a0d72032c98a537ed52da5b..3113a65baaa818c4de238caf6cb724130b4992b7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
 /vendor
 /composer.lock
 /dist
+/out
 /node_modules
 .php_cs.*cache
 .idea
diff --git a/composer.json b/composer.json
index 794c815b00bf575641a11c4fa966a4e0e6dd2ada..51d0020dca849eb56888d3d1d7ec6387f1e31b40 100644
--- a/composer.json
+++ b/composer.json
@@ -44,6 +44,9 @@
         "sebastian/phpcpd": "^4.0"
     },
     "scripts": {
+      "post-install-cmd": "@nodejs-build",
+      "post-update-cmd": "@nodejs-build",
+      "nodejs-build": "npm install && npm run-script build || exit 0",
       "fix-cs": "@php vendor/bin/php-cs-fixer fix --verbose",
       "phploc": "@php vendor/bin/phploc --no-interaction src/php tests",
       "test": [
diff --git a/package-lock.json b/package-lock.json
index 7b82f5b2cce22f6c310df1d259770bfc2287ecce..db7ae56286e6dcef07e1a416641a736007c43014 100644
Binary files a/package-lock.json and b/package-lock.json differ
diff --git a/package.json b/package.json
index 7466024a1227ef4c5ddfa552ea6d74a2dfdc9739..cbe80b8ddbf2f6daf9a1cf9750c655ea3e9da6fb 100644
--- a/package.json
+++ b/package.json
@@ -2,10 +2,16 @@
   "name": "irstea-ng-model",
   "version": "1.0.0",
   "description": "Runtime libray for the composer package irstea/ng-model-generator-bundle.",
+  "types": "dist/index.d.ts",
   "main": "dist/index.js",
+  "module": "dist/index.esm.js",
   "directories": {},
   "scripts": {
-    "test": "echo \"Error: no test specified\" && exit 1"
+    "test": "echo \"Error: no test specified\" && exit 1",
+    "prebuild": "rm -rf dist/ && tsc -p ./tsconfig.declaration.json",
+    "build": "tsc -p . && rollup -c",
+    "postbuild": "rm -rf out",
+    "clean": "rm -rf dist/* out/*"
   },
   "repository": {
     "type": "git",
@@ -18,26 +24,54 @@
   "author": "Irstea - pôle IS",
   "license": "LGPL-3.0-or-later",
   "peerDependencies": {
-    "@angular/common": "^7.2.9",
-    "@angular/core": "^7.2.9"
+    "@angular/common": "^7.2.15"
   },
   "dependencies": {
     "lodash": "^4.17.11",
-    "rxjs": "^6.4.0",
-    "rxjs-etc": "^9.4.0"
+    "rxjs": "^6.5.2",
+    "rxjs-etc": "^9.5.0",
+    "tslib": "^1.9.3"
   },
   "devDependencies": {
-    "@angular/common": "^7.2.9",
-    "@angular/core": "^7.2.9",
-    "irstea-typescript-config": "^1.0.3",
-    "prettier": "^1.16.4",
+    "@angular/common": "^7.2.15",
+    "@angular/compiler": "^7.2.15",
+    "@angular/core": "^7.2.15",
+    "codelyzer": "^5.0.1",
+    "husky": "^2.3.0",
+    "irstea-typescript-config": "^1.0.6",
+    "lint-staged": "^8.1.7",
+    "prettier": "^1.17.1",
     "prettier-tslint": "^0.4.2",
-    "rxjs-marbles": "^5.0.0",
-    "rxjs-tslint-rules": "^4.18.2",
-    "tslint": "^5.14.0",
+    "rollup": "^1.12.1",
+    "rollup-plugin-node-resolve": "^5.0.0",
+    "rxjs-marbles": "^5.0.2",
+    "rxjs-tslint-rules": "^4.23.1",
+    "tslint": "^5.16.0",
     "tslint-config-prettier": "^1.18.0",
+    "tslint-defocus": "^2.0.6",
     "tslint-plugin-prettier": "^2.0.1",
-    "typescript": "^3.3.3333",
+    "typescript": "^3.4.5",
     "zone.js": "~0.8.26"
+  },
+  "husky": {
+    "hooks": {
+      "pre-commit": "lint-staged"
+    }
+  },
+  "lint-staged": {
+    "src/**/*.{json,md}": [
+      "prettier --write",
+      "git add"
+    ],
+    "src/**/*.ts": [
+      "prettier-tslint fix",
+      "git add"
+    ]
+  },
+  "prettier": {
+    "printWidth": 80,
+    "semi": true,
+    "singleQuote": true,
+    "trailingComma": "es5"
   }
 }
diff --git a/rollup.config.js b/rollup.config.js
new file mode 100644
index 0000000000000000000000000000000000000000..5911d8511287b426e4584031be3cc3539887d232
--- /dev/null
+++ b/rollup.config.js
@@ -0,0 +1,16 @@
+import resolve from 'rollup-plugin-node-resolve';
+
+import pkg from './package.json';
+
+export default [
+  // CommonJS (for Node) and ES module (for bundlers) build.
+  {
+    input: 'out/index.js',
+    external: Object.keys(pkg.dependencies).concat(Object.keys(pkg.peerDependencies)),
+    output: [
+      { file: pkg.main, format: 'cjs' },
+      { file: pkg.module, format: 'es' },
+    ],
+    plugins: [resolve()],
+  },
+];
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/index.ts b/src/ts/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..26160106861f5263eb0a59ace616e6c096ac3ec0
--- /dev/null
+++ b/src/ts/index.ts
@@ -0,0 +1,3 @@
+export * from './metadata';
+export * from './service';
+export * from './types';
diff --git a/src/ts/metadata/index.ts b/src/ts/metadata/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..93f3e0674e98b5d011a0f7ea02c9bf63fb739c40
--- /dev/null
+++ b/src/ts/metadata/index.ts
@@ -0,0 +1,3 @@
+export * from './iri.metadata';
+export * from './resource.metadata';
+export * from './registry';
diff --git a/src/ts/metadata/iri.metadata.ts b/src/ts/metadata/iri.metadata.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1f34dd3470ef8989ef162ed4bc34756e186a54a7
--- /dev/null
+++ b/src/ts/metadata/iri.metadata.ts
@@ -0,0 +1,40 @@
+import { IRI } from '../types';
+
+/**
+ * Type des paramètres
+ */
+export type IRIParameters = string[] | string;
+
+/**
+ * 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;
+  }
+}
diff --git a/src/ts/metadata/registry.ts b/src/ts/metadata/registry.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9c01077f2797e3abbf8afdafce38fa844ec84e21
--- /dev/null
+++ b/src/ts/metadata/registry.ts
@@ -0,0 +1,68 @@
+import { AbstractRepository } from '../service';
+import { Resource } from '../types';
+
+import { IRIParameters } from './iri.metadata';
+import { ResourceMetadata } from './resource.metadata';
+
+/**
+ * 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 = {} as { [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'] {
+    let metadata = this.instances[type];
+    if (!metadata) {
+      metadata = this.instances[type] = this.builders[type](this);
+    }
+    return metadata;
+  }
+}
diff --git a/src/ts/metadata/resource.metadata.ts b/src/ts/metadata/resource.metadata.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ed75e538a4c987a0d25fbdf31f9afcb2cc6f9a24
--- /dev/null
+++ b/src/ts/metadata/resource.metadata.ts
@@ -0,0 +1,56 @@
+import { getResourceType, IRI, Resource } from '../types';
+
+import { IRIMetadata, IRIParameters } from './iri.metadata';
+
+/**
+ * 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: Array<keyof R>,
+    private readonly types: Array<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: 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/service/abstract-repository.ts b/src/ts/service/abstract-repository.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0620ded78bea8340b1113f277f8ab4f2e76d40c3
--- /dev/null
+++ b/src/ts/service/abstract-repository.ts
@@ -0,0 +1,42 @@
+import { HttpClient } from '@angular/common/http';
+
+import { APIMeta, IRIParameters, ResourceMetadata } from '../metadata';
+import { IRI, Resource } from '../types';
+
+import { AbstractResourceCache } from './abstract-resource-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);
+  }
+}
+
+/**
+ * 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'];
+}
diff --git a/src/ts/service/abstract-resource-cache.ts b/src/ts/service/abstract-resource-cache.ts
new file mode 100644
index 0000000000000000000000000000000000000000..558a87e2916ea59db84de82af670606ca92e5cd6
--- /dev/null
+++ b/src/ts/service/abstract-resource-cache.ts
@@ -0,0 +1,63 @@
+import { HttpResponseBase } from '@angular/common/http';
+import { Observable } from 'rxjs';
+
+import { Collection, IRI, Resource } from '../types';
+
+/**
+ * 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/service/api.ts b/src/ts/service/api.ts
new file mode 100644
index 0000000000000000000000000000000000000000..19e9d12183bbffc62151e115b4c7027ead802860
--- /dev/null
+++ b/src/ts/service/api.ts
@@ -0,0 +1,128 @@
+import { HttpClient, HttpHeaders } from '@angular/common/http';
+import { forkJoin, Observable } from 'rxjs';
+
+import { APIMeta, APIMetadataRegistry } from '../metadata';
+import { IRI, Resource } from '../types';
+
+import { APIRepositoryRegistry } from './abstract-repository';
+import { AbstractResourceCache } from './abstract-resource-cache';
+
+/**
+ * Options supplémentaires pour les requêtes.
+ */
+export interface RequestOptions {
+  body?: any;
+  headers?:
+    | HttpHeaders
+    | {
+        [header: string]: string | string[];
+      };
+  params?: { [param: string]: string | string[] };
+}
+
+/**
+ * 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: Array<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: Array<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/service/index.ts b/src/ts/service/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..269a008b0b2105e404208e17282f688faf696b2e
--- /dev/null
+++ b/src/ts/service/index.ts
@@ -0,0 +1,3 @@
+export * from './abstract-repository';
+export * from './abstract-resource-cache';
+export * from './api';
diff --git a/src/ts/types/collection.ts b/src/ts/types/collection.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3c286890efc3e6b8076c8fc33b8f4c690b90aac0
--- /dev/null
+++ b/src/ts/types/collection.ts
@@ -0,0 +1,39 @@
+import { Resource } 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]: 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];
+}
diff --git a/src/ts/types/date-time.ts b/src/ts/types/date-time.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f4047319c3ecb0c17e261c8f81386bf6bf26bc62
--- /dev/null
+++ b/src/ts/types/date-time.ts
@@ -0,0 +1,4 @@
+/**
+ * Full DateTime in ISO-8601 format.
+ */
+export type DateTime = string;
diff --git a/src/ts/types/index.ts b/src/ts/types/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c0568a263e8deaf50787ce669de81e26de17ae82
--- /dev/null
+++ b/src/ts/types/index.ts
@@ -0,0 +1,4 @@
+export * from './collection';
+export * from './date-time';
+export * from './resource';
+export * from './uuid';
diff --git a/src/ts/types/resource.ts b/src/ts/types/resource.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1848919b7e8e38736d2244240ee8d249885b6619
--- /dev/null
+++ b/src/ts/types/resource.ts
@@ -0,0 +1,53 @@
+/**
+ * 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> extends String {
+  readonly [IRI]?: R;
+}
+
+/**
+ * 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';
+
+/**
+ * Resource
+ */
+export interface Resource {
+  readonly [IRI_PROPERTY]: IRI<any>;
+  readonly [TYPE_PROPERTY]: string;
+  [property: string]: any;
+}
+
+/**
+ * Vérifie que l'argument est une ressource.
+ */
+export function isResource(what: unknown): what is Resource {
+  return (
+    typeof what === 'object' &&
+    what !== null &&
+    TYPE_PROPERTY in what &&
+    typeof (what as any)[TYPE_PROPERTY] === 'string'
+  );
+}
+
+/**
+ * Retourne le type d'une ressource, ou null
+ */
+export function getResourceType(that: unknown): string | null {
+  return isResource(that) ? that[TYPE_PROPERTY] : null;
+}
diff --git a/src/ts/types/uuid.ts b/src/ts/types/uuid.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8dda7a7f1b505b4f298cf194341b2dae078f6c24
--- /dev/null
+++ b/src/ts/types/uuid.ts
@@ -0,0 +1,16 @@
+/**
+ * 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: unknown): 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
+    )
+  );
+}
diff --git a/tsconfig.declaration.json b/tsconfig.declaration.json
new file mode 100644
index 0000000000000000000000000000000000000000..ef88678a036029c12eefbac255413a1b75cb2dc3
--- /dev/null
+++ b/tsconfig.declaration.json
@@ -0,0 +1,9 @@
+{
+  "extends": "./tsconfig.json",
+  "include": ["src/ts/index.ts"],
+  "compilerOptions": {
+    "outFile": "dist/index.d.ts",
+    "declaration": true,
+    "emitDeclarationOnly": true
+  }
+}
diff --git a/tsconfig.json b/tsconfig.json
index 48c1e4955e1a4137fb24fdc9680564539506e0e0..4764da3b945c802afe1eaf14c36ac9e8287198da 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,23 +1,29 @@
 {
   "extends": "./node_modules/irstea-typescript-config/tsconfig.json",
   "compileOnSave": false,
+  "include": ["src/ts/**/*"],
+  "exclude": ["src/ts/**/*.spec.ts"],
   "compilerOptions": {
-    "outDir": "./dist/out-tsc",
-    "sourceMap": false,
-    "inlineSourceMap": false,
-    "declaration": false,
+    "baseUrl": "./",
+    "paths": {},
+    "strict": true,
+    "lib": ["dom", "es2017"],
+    "typeRoots": ["node_modules/@types"],
+    "outDir": "out/",
+    "target": "es5",
+    "module": "esnext",
     "moduleResolution": "node",
+    "declaration": false,
+    "importHelpers": true,
+    "noEmitHelpers": true,
+    "noEmitOnError": true,
+    "pretty": true,
+    "removeComments": true,
+    "sourceMap": true,
+    "stripInternal": true,
+    "inlineSourceMap": false,
     "emitDecoratorMetadata": true,
     "experimentalDecorators": true,
-    "importHelpers": true,
-    "target": "es5",
-    "typeRoots": [
-      "node_modules/@types"
-    ],
-    "lib": [
-      "es2017",
-      "dom"
-    ],
     "locale": "fr"
   }
 }