diff --git a/lib/util/cache/package/cacheable.spec.ts b/lib/util/cache/package/cacheable.spec.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8c19c1fcd1161c60459b1ff459ee2fa162aa93df
--- /dev/null
+++ b/lib/util/cache/package/cacheable.spec.ts
@@ -0,0 +1,127 @@
+import { DateTime } from 'luxon';
+import { Cacheable } from './cacheable';
+
+describe('util/cache/package/cacheable', () => {
+  it('constructs default value', () => {
+    const res = Cacheable.empty();
+    expect(res.ttlMinutes).toBe(15);
+  });
+
+  describe('TTL', () => {
+    it('static method for minutes', () => {
+      const res = Cacheable.forMinutes(123);
+      expect(res.ttlMinutes).toBe(123);
+    });
+
+    it('method for minutes', () => {
+      const res = Cacheable.empty();
+      expect(res.forMinutes(42).ttlMinutes).toBe(42);
+    });
+
+    it('setter for minutes', () => {
+      const res = Cacheable.empty();
+      res.ttlMinutes = 42;
+      expect(res.ttlMinutes).toBe(42);
+    });
+
+    it('static method for hours', () => {
+      const res = Cacheable.forHours(3);
+      expect(res.ttlMinutes).toBe(180);
+    });
+
+    it('method for hours', () => {
+      const res = Cacheable.empty();
+      expect(res.forHours(3).ttlMinutes).toBe(180);
+    });
+
+    it('setter for hours', () => {
+      const res = Cacheable.empty();
+      res.ttlHours = 3;
+      expect(res.ttlMinutes).toBe(180);
+    });
+
+    it('static method for days', () => {
+      const res = Cacheable.forDays(2);
+      expect(res.ttlMinutes).toBe(2880);
+    });
+
+    it('method for days', () => {
+      const res = Cacheable.empty();
+      expect(res.forDays(2).ttlMinutes).toBe(2880);
+    });
+
+    it('setter for days', () => {
+      const res = Cacheable.empty();
+      res.ttlDays = 2;
+      expect(res.ttlMinutes).toBe(2880);
+    });
+  });
+
+  describe('public data', () => {
+    it('via static method', () => {
+      const res: Cacheable<number> = Cacheable.fromPublic(42);
+      expect(res.value).toBe(42);
+      expect(res.isPublic).toBeTrue();
+      expect(res.isPrivate).toBeFalse();
+    });
+
+    it('via method', () => {
+      const res: Cacheable<number> = Cacheable.empty().asPublic(42);
+      expect(res.value).toBe(42);
+      expect(res.isPublic).toBeTrue();
+      expect(res.isPrivate).toBeFalse();
+    });
+  });
+
+  describe('private data', () => {
+    it('via static method', () => {
+      const res: Cacheable<number> = Cacheable.fromPrivate(42);
+      expect(res.value).toBe(42);
+      expect(res.isPublic).toBeFalse();
+      expect(res.isPrivate).toBeTrue();
+    });
+
+    it('via method', () => {
+      const res: Cacheable<number> = Cacheable.empty().asPrivate(42);
+      expect(res.value).toBe(42);
+      expect(res.isPublic).toBeFalse();
+      expect(res.isPrivate).toBeTrue();
+    });
+  });
+
+  describe('timestamping', () => {
+    function dateOf<T>(cacheableResult: Cacheable<T>): Date {
+      return DateTime.fromISO(cacheableResult.cachedAt).toJSDate();
+    }
+
+    it('handles dates automatically', () => {
+      const t1 = new Date();
+
+      const empty = Cacheable.empty();
+
+      const t2 = new Date();
+
+      const a = Cacheable.fromPrivate(42);
+      const b = Cacheable.fromPublic(42);
+      const c = empty.asPrivate(42);
+      const d = empty.asPublic(42);
+
+      const t3 = new Date();
+
+      expect(dateOf(empty)).toBeAfterOrEqualTo(t1);
+      expect(dateOf(empty)).toBeBeforeOrEqualTo(t2);
+
+      expect(dateOf(a)).toBeAfterOrEqualTo(t2);
+      expect(dateOf(a)).toBeBeforeOrEqualTo(t3);
+
+      expect(dateOf(b)).toBeAfterOrEqualTo(t2);
+      expect(dateOf(b)).toBeBeforeOrEqualTo(t3);
+
+      expect(dateOf(c)).toBeAfterOrEqualTo(t2);
+      expect(dateOf(c)).toBeBeforeOrEqualTo(t3);
+
+      expect(dateOf(d)).toBeAfterOrEqualTo(t2);
+      expect(dateOf(d)).toBeBeforeOrEqualTo(t3);
+    });
+  });
+});
diff --git a/lib/util/cache/package/cacheable.ts b/lib/util/cache/package/cacheable.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ca50b579ecafabb2c220afdbd1f392e89267a330
--- /dev/null
+++ b/lib/util/cache/package/cacheable.ts
@@ -0,0 +1,206 @@
+import { DateTime } from 'luxon';
+
+/**
+ * Type that could be reliably cached, including `null` values.
+ */
+type NotUndefined<T> = T extends undefined ? never : T;
+
+/**
+ * Type used for partially initialized `Cacheable` values.
+ */
+type MaybeUndefined<T> = NotUndefined<T> | undefined;
+
+/**
+ * Default cache TTL.
+ */
+const defaultTtlMinutes = 15;
+
+export class Cacheable<T> {
+  private constructor(
+    private _ttlMinutes: number,
+    private _cachedAt: DateTime<true>,
+    private _isPrivate: boolean,
+    private _value: T,
+  ) {}
+
+  /**
+   * Constructs an empty instance for further modification.
+   */
+  public static empty<T>(): Cacheable<MaybeUndefined<T>> {
+    return new Cacheable<MaybeUndefined<T>>(
+      defaultTtlMinutes,
+      DateTime.now(),
+      true,
+      undefined,
+    );
+  }
+
+  /**
+   * Returns the TTL in minutes.
+   */
+  get ttlMinutes(): number {
+    return this._ttlMinutes;
+  }
+
+  /**
+   * Set the TTL in minutes.
+   */
+  set ttlMinutes(minutes: number) {
+    this._ttlMinutes = minutes;
+  }
+
+  /**
+   * Set the TTL in hours.
+   */
+  set ttlHours(hours: number) {
+    this._ttlMinutes = 60 * hours;
+  }
+
+  /**
+   * Set the TTL in days.
+   */
+  set ttlDays(hours: number) {
+    this._ttlMinutes = 24 * 60 * hours;
+  }
+
+  /**
+   * Sets the cache TTL in minutes and returns the same object.
+   */
+  public forMinutes(minutes: number): Cacheable<T> {
+    this.ttlMinutes = minutes;
+    return this;
+  }
+
+  /**
+   * Construct the empty `Cacheable` instance with pre-configured minutes of TTL.
+   */
+  public static forMinutes<T>(minutes: number): Cacheable<MaybeUndefined<T>> {
+    return Cacheable.empty<MaybeUndefined<T>>().forMinutes(minutes);
+  }
+
+  /**
+   * Sets the cache TTL in hours and returns the same object.
+   */
+  public forHours(hours: number): Cacheable<T> {
+    return this.forMinutes(60 * hours);
+  }
+
+  /**
+   * Construct the empty `Cacheable` instance with pre-configured hours of TTL.
+   */
+  public static forHours<T>(hours: number): Cacheable<MaybeUndefined<T>> {
+    return Cacheable.empty<MaybeUndefined<T>>().forHours(hours);
+  }
+
+  /**
+   * Sets the cache TTL in days and returns the same object.
+   */
+  public forDays(days: number): Cacheable<T> {
+    return this.forHours(24 * days);
+  }
+
+  /**
+   * Construct the empty `Cacheable` instance with pre-configured hours of TTL.
+   */
+  public static forDays<T>(days: number): Cacheable<MaybeUndefined<T>> {
+    return Cacheable.empty<MaybeUndefined<T>>().forDays(days);
+  }
+
+  /**
+   * Construct `Cacheable` instance that SHOULD be persisted and available publicly.
+   *
+   * @param value Data to cache
+   * @returns New `Cacheable` instance with the `value` guaranteed to be defined.
+   */
+  public static fromPublic<T>(
+    value: NotUndefined<T>,
+  ): Cacheable<NotUndefined<T>> {
+    return new Cacheable<NotUndefined<T>>(
+      defaultTtlMinutes,
+      DateTime.now(),
+      false,
+      value,
+    );
+  }
+
+  /**
+   * Mark the partially initialized `Cacheable` instance as public,
+   * for data that SHOULD be persisted and available publicly.
+   *
+   * @param value Data to cache
+   * @returns New `Cacheable` instance with `value` guaranteed to be defined.
+   */
+  public asPublic<T>(value: NotUndefined<T>): Cacheable<NotUndefined<T>> {
+    return new Cacheable<NotUndefined<T>>(
+      this._ttlMinutes,
+      this._cachedAt,
+      false,
+      value,
+    );
+  }
+
+  /**
+   * Construct `Cacheable` instance that MUST NOT be available publicly,
+   * but still COULD be persisted in self-hosted setups.
+   *
+   * @param value Data to cache
+   * @returns New `Cacheable` instance with `value` guaranteed to be defined.
+   */
+  public static fromPrivate<T>(
+    value: NotUndefined<T>,
+  ): Cacheable<NotUndefined<T>> {
+    return new Cacheable<NotUndefined<T>>(
+      defaultTtlMinutes,
+      DateTime.now(),
+      true,
+      value,
+    );
+  }
+
+  /**
+   * Mark the partially initialized `Cacheable` instance as private,
+   * for data that MUST NOT be available publicly,
+   * but still COULD be persisted in self-hosted setups.
+   *
+   * @param value Data to cache
+   * @returns New `Cacheable` instance with `value` guaranteed to be defined.
+   */
+  public asPrivate<T>(value: NotUndefined<T>): Cacheable<NotUndefined<T>> {
+    return new Cacheable<NotUndefined<T>>(
+      this._ttlMinutes,
+      this._cachedAt,
+      true,
+      value,
+    );
+  }
+
+  /**
+   * Check whether the instance is private.
+   */
+  get isPrivate(): boolean {
+    return this._isPrivate;
+  }
+
+  /**
+   * Check whether the instance is public.
+   */
+  get isPublic(): boolean {
+    return !this._isPrivate;
+  }
+
+  /**
+   * Cached value
+   */
+  get value(): T {
+    return this._value;
+  }
+
+  /**
+   * The creation date of the cached value,
+   * which is set during `fromPrivate`, `asPrivate`,
+   * `fromPublic`, or `asPublic` calls.
+   */
+  get cachedAt(): string {
+    return this._cachedAt.toUTC().toISO();
+  }
+}