diff --git a/packages/datasource-customizer/src/decorators/decorators-stack.ts b/packages/datasource-customizer/src/decorators/decorators-stack.ts
index ecb0c217e1..58fdf8c16c 100644
--- a/packages/datasource-customizer/src/decorators/decorators-stack.ts
+++ b/packages/datasource-customizer/src/decorators/decorators-stack.ts
@@ -7,6 +7,7 @@ import ComputedCollectionDecorator from './computed/collection';
 import DecoratorsStackBase, { Options } from './decorators-stack-base';
 import EmptyCollectionDecorator from './empty/collection';
 import HookCollectionDecorator from './hook/collection';
+import LazyJoinDecorator from './lazy-join/collection';
 import OperatorsEmulateCollectionDecorator from './operators-emulate/collection';
 import OperatorsEquivalenceCollectionDecorator from './operators-equivalence/collection';
 import OverrideCollectionDecorator from './override/collection';
@@ -39,6 +40,7 @@ export default class DecoratorsStack extends DecoratorsStackBase {
     last = this.earlyOpEmulate = new DataSourceDecorator(last, OperatorsEmulateCollectionDecorator);
     last = new DataSourceDecorator(last, OperatorsEquivalenceCollectionDecorator);
     last = this.relation = new DataSourceDecorator(last, RelationCollectionDecorator);
+    last = new DataSourceDecorator(last, LazyJoinDecorator);
     last = this.lateComputed = new DataSourceDecorator(last, ComputedCollectionDecorator);
     last = this.lateOpEmulate = new DataSourceDecorator(last, OperatorsEmulateCollectionDecorator);
     last = new DataSourceDecorator(last, OperatorsEquivalenceCollectionDecorator);
diff --git a/packages/datasource-customizer/src/decorators/lazy-join/collection.ts b/packages/datasource-customizer/src/decorators/lazy-join/collection.ts
new file mode 100644
index 0000000000..6575cf113c
--- /dev/null
+++ b/packages/datasource-customizer/src/decorators/lazy-join/collection.ts
@@ -0,0 +1,110 @@
+import {
+  AggregateResult,
+  Aggregation,
+  Caller,
+  CollectionDecorator,
+  FieldSchema,
+  Filter,
+  ManyToOneSchema,
+  PaginatedFilter,
+  Projection,
+  RecordData,
+} from '@forestadmin/datasource-toolkit';
+
+export default class LazyJoinDecorator extends CollectionDecorator {
+  override async list(
+    caller: Caller,
+    filter: PaginatedFilter,
+    projection: Projection,
+  ): Promise<RecordData[]> {
+    const refinedProjection = projection.replace(field => this.refineField(field, projection));
+    const refinedFilter = await this.refineFilter(caller, filter);
+
+    const records = await this.childCollection.list(caller, refinedFilter, refinedProjection);
+
+    this.refineResults(projection, (relationName, foreignKey, foreignKeyTarget) => {
+      records.forEach(record => {
+        if (record[foreignKey]) {
+          record[relationName] = { [foreignKeyTarget]: record[foreignKey] };
+        }
+
+        delete record[foreignKey];
+      });
+    });
+
+    return records;
+  }
+
+  override async aggregate(
+    caller: Caller,
+    filter: Filter,
+    aggregation: Aggregation,
+    limit?: number,
+  ): Promise<AggregateResult[]> {
+    const refinedAggregation = aggregation.replaceFields(field =>
+      this.refineField(field, aggregation.projection),
+    );
+    const refinedFilter = await this.refineFilter(caller, filter);
+
+    const results = await this.childCollection.aggregate(
+      caller,
+      refinedFilter,
+      refinedAggregation,
+      limit,
+    );
+
+    this.refineResults(aggregation.projection, (relationName, foreignKey, foreignKeyTarget) => {
+      results.forEach(result => {
+        if (result.group[foreignKey]) {
+          result.group[`${relationName}:${foreignKeyTarget}`] = result.group[foreignKey];
+        }
+
+        delete result.group[foreignKey];
+      });
+    });
+
+    return results;
+  }
+
+  private isLazyRelationProjection(relation: FieldSchema, relationProjection: Projection) {
+    return (
+      relation.type === 'ManyToOne' &&
+      relationProjection.length === 1 &&
+      relationProjection[0] === relation.foreignKeyTarget
+    );
+  }
+
+  private refineField(field: string, projection: Projection): string {
+    const relationName = field.split(':')[0];
+    const relation = this.schema.fields[relationName] as ManyToOneSchema;
+    const relationProjection = projection.relations[relationName];
+
+    return this.isLazyRelationProjection(relation, relationProjection)
+      ? relation.foreignKey
+      : field;
+  }
+
+  override async refineFilter(caller: Caller, filter: PaginatedFilter): Promise<PaginatedFilter> {
+    if (filter.conditionTree) {
+      filter.conditionTree = filter.conditionTree.replaceFields(field =>
+        this.refineField(field, filter.conditionTree.projection),
+      );
+    }
+
+    return filter;
+  }
+
+  private refineResults(
+    projection: Projection,
+    handler: (relationName: string, foreignKey: string, foreignKeyTarget: string) => void,
+  ) {
+    Object.entries(projection.relations).forEach(([relationName, relationProjection]) => {
+      const relation = this.schema.fields[relationName] as ManyToOneSchema;
+
+      if (this.isLazyRelationProjection(relation, relationProjection)) {
+        const { foreignKeyTarget, foreignKey } = relation;
+        handler(relationName, foreignKey, foreignKeyTarget);
+      }
+    });
+  }
+}
diff --git a/packages/datasource-customizer/test/decorators/lazy-join/collection.test.ts b/packages/datasource-customizer/test/decorators/lazy-join/collection.test.ts
new file mode 100644
index 0000000000..44cc0fca46
--- /dev/null
+++ b/packages/datasource-customizer/test/decorators/lazy-join/collection.test.ts
@@ -0,0 +1,350 @@
+import {
+  Aggregation,
+  Collection,
+  DataSource,
+  DataSourceDecorator,
+  Projection,
+} from '@forestadmin/datasource-toolkit';
+import * as factories from '@forestadmin/datasource-toolkit/dist/test/__factories__';
+
+import LazyJoinDecorator from '../../../src/decorators/lazy-join/collection';
+
+describe('LazyJoinDecorator', () => {
+  let dataSource: DataSource;
+  let decoratedDataSource: DataSourceDecorator<LazyJoinDecorator>;
+
+  let transactions: Collection;
+  let decoratedTransactions: LazyJoinDecorator;
+
+  beforeEach(() => {
+    const card = factories.collection.build({
+      name: 'cards',
+      schema: factories.collectionSchema.build({
+        fields: {
+          id: factories.columnSchema.uuidPrimaryKey().build(),
+          type: factories.columnSchema.build(),
+        },
+      }),
+    });
+
+    const user = factories.collection.build({
+      name: 'uses',
+      schema: factories.collectionSchema.build({
+        fields: {
+          id: factories.columnSchema.uuidPrimaryKey().build(),
+          name: factories.columnSchema.build(),
+        },
+      }),
+    });
+
+    transactions = factories.collection.build({
+      name: 'transactions',
+      schema: factories.collectionSchema.build({
+        fields: {
+          id: factories.columnSchema.uuidPrimaryKey().build(),
+          description: factories.columnSchema.build(),
+          amountInEur: factories.columnSchema.build(),
+          card: factories.manyToOneSchema.build({
+            foreignCollection: 'cards',
+            foreignKey: 'card_id',
+          }),
+          user: factories.manyToOneSchema.build({
+            foreignCollection: 'users',
+            foreignKey: 'user_id',
+          }),
+        },
+      }),
+    });
+
+    dataSource = factories.dataSource.buildWithCollections([card, user, transactions]);
+    decoratedDataSource = new DataSourceDecorator(dataSource, LazyJoinDecorator);
+    decoratedTransactions = decoratedDataSource.getCollection('transactions');
+  });
+
+  describe('list', () => {
+    describe('when projection ask for foreign key only', () => {
+      test('it should not join', async () => {
+        const spy = jest.spyOn(transactions, 'list');
+        spy.mockResolvedValue([{ id: 1, card_id: 2 }]);
+
+        const caller = factories.caller.build();
+        const filter = factories.filter.build();
+
+        const records = await decoratedTransactions.list(
+          caller,
+          filter,
+          new Projection('id', 'card:id'),
+        );
+
+        expect(spy).toHaveBeenCalledWith(caller, filter, new Projection('id', 'card_id'));
+        expect(records).toStrictEqual([{ id: 1, card: { id: 2 } }]);
+      });
+
+      test('it should work with multiple relations', async () => {
+        const spy = jest.spyOn(transactions, 'list');
+        spy.mockResolvedValue([{ id: 1, card_id: 2, user_id: 3 }]);
+
+        const caller = factories.caller.build();
+        const filter = factories.filter.build();
+
+        const records = await decoratedTransactions.list(
+          caller,
+          filter,
+          new Projection('id', 'card:id', 'user:id'),
+        );
+
+        expect(spy).toHaveBeenCalledWith(
+          caller,
+          filter,
+          new Projection('id', 'card_id', 'user_id'),
+        );
+        expect(records).toStrictEqual([{ id: 1, card: { id: 2 }, user: { id: 3 } }]);
+      });
+
+      test('it should disable join on projection but not in condition tree', async () => {
+        const spy = jest.spyOn(transactions, 'list');
+        spy.mockResolvedValue([{ id: 1, card_id: 2 }]);
+
+        const caller = factories.caller.build();
+        const filter = factories.filter.build({
+          conditionTree: factories.conditionTreeLeaf.build({
+            field: 'card:type',
+            operator: 'Equal',
+            value: 'Visa',
+          }),
+        });
+
+        const records = await decoratedTransactions.list(
+          caller,
+          filter,
+          new Projection('id', 'card:id'),
+        );
+
+        expect(spy).toHaveBeenCalledWith(caller, filter, new Projection('id', 'card_id'));
+        expect(records).toStrictEqual([{ id: 1, card: { id: 2 } }]);
+      });
+    });
+
+    describe('when projection ask for multiple fields in foreign collection', () => {
+      test('it should join', async () => {
+        const spy = jest.spyOn(transactions, 'list');
+        spy.mockResolvedValue([{ id: 1, card: { id: 2, type: 'Visa' } }]);
+
+        const caller = factories.caller.build();
+        const filter = factories.filter.build();
+        const projection = new Projection('id', 'card:id', 'card:type');
+
+        const records = await decoratedTransactions.list(caller, filter, projection);
+
+        expect(spy).toHaveBeenCalledWith(caller, filter, projection);
+        expect(records).toStrictEqual([{ id: 1, card: { id: 2, type: 'Visa' } }]);
+      });
+    });
+
+    describe('when condition tree is on foreign key only', () => {
+      test('it should not join', async () => {
+        const spy = jest.spyOn(transactions, 'list');
+        spy.mockResolvedValue([{ id: 1, card: { id: 2, type: 'Visa' } }]);
+
+        const caller = factories.caller.build();
+        const projection = new Projection('id', 'card:id', 'card:type');
+        const filter = factories.filter.build({
+          conditionTree: factories.conditionTreeLeaf.build({
+            field: 'card:id',
+            operator: 'Equal',
+            value: '2',
+          }),
+        });
+
+        await decoratedTransactions.list(caller, filter, projection);
+
+        const expectedFilter = factories.filter.build({
+          conditionTree: factories.conditionTreeLeaf.build({
+            field: 'card_id',
+            operator: 'Equal',
+            value: '2',
+          }),
+        });
+
+        expect(spy).toHaveBeenCalledWith(caller, expectedFilter, projection);
+      });
+    });
+
+    describe('when condition tree is on foreign collection fields', () => {
+      test('it should join', async () => {
+        const spy = jest.spyOn(transactions, 'list');
+        spy.mockResolvedValue([{ id: 1, card: { id: 2, type: 'Visa' } }]);
+
+        const caller = factories.caller.build();
+        const projection = new Projection('id', 'card:id', 'card:type');
+        const filter = factories.filter.build({
+          conditionTree: factories.conditionTreeLeaf.build({
+            field: 'card:type',
+            operator: 'Equal',
+            value: 'Visa',
+          }),
+        });
+
+        await decoratedTransactions.list(caller, filter, projection);
+
+        expect(spy).toHaveBeenCalledWith(caller, filter, projection);
+      });
+    });
+
+    test('it should correctly handle null relations', async () => {
+      const spy = jest.spyOn(transactions, 'list');
+      spy.mockResolvedValue([{ id: 1, card_id: null }]);
+
+      const caller = factories.caller.build();
+      const filter = factories.filter.build();
+
+      const records = await decoratedTransactions.list(
+        caller,
+        filter,
+        new Projection('id', 'card:id'),
+      );
+
+      expect(spy).toHaveBeenCalledWith(caller, filter, new Projection('id', 'card_id'));
+      expect(records).toStrictEqual([{ id: 1 }]);
+    });
+  });
+
+  describe('aggregate', () => {
+    describe('when group by foreign pk', () => {
+      test('it should not join', async () => {
+        const spy = jest.spyOn(transactions, 'aggregate');
+        spy.mockResolvedValue([
+          { value: 1824.11, group: { user_id: 1 } },
+          { value: 824, group: { user_id: 2 } },
+        ]);
+
+        const caller = factories.caller.build();
+        const filter = factories.filter.build();
+
+        const results = await decoratedTransactions.aggregate(
+          caller,
+          filter,
+          new Aggregation({
+            operation: 'Sum',
+            field: 'amountInEur',
+            groups: [{ field: 'user:id' }],
+          }),
+          1,
+        );
+
+        expect(spy).toHaveBeenCalledWith(
+          caller,
+          filter,
+          new Aggregation({
+            operation: 'Sum',
+            field: 'amountInEur',
+            groups: [{ field: 'user_id' }],
+          }),
+          1,
+        );
+        expect(results).toStrictEqual([
+          { value: 1824.11, group: { 'user:id': 1 } },
+          { value: 824, group: { 'user:id': 2 } },
+        ]);
+      });
+    });
+
+    describe('when group by foreign field', () => {
+      test('it should join', async () => {
+        const spy = jest.spyOn(transactions, 'aggregate');
+        spy.mockResolvedValue([
+          { value: 1824.11, group: { 'user:name': 'Brad' } },
+          { value: 824, group: { 'user:name': 'Pit' } },
+        ]);
+
+        const caller = factories.caller.build();
+        const filter = factories.filter.build();
+        const aggregation = new Aggregation({
+          operation: 'Sum',
+          field: 'amountInEur',
+          groups: [{ field: 'user:name' }],
+        });
+
+        const results = await decoratedTransactions.aggregate(caller, filter, aggregation, 1);
+
+        expect(spy).toHaveBeenCalledWith(caller, filter, aggregation, 1);
+        expect(results).toStrictEqual([
+          { value: 1824.11, group: { 'user:name': 'Brad' } },
+          { value: 824, group: { 'user:name': 'Pit' } },
+        ]);
+      });
+    });
+
+    describe('when filter on foreign pk', () => {
+      test('it should not join', async () => {
+        const spy = jest.spyOn(transactions, 'aggregate');
+        spy.mockResolvedValue([
+          { value: 1824.11, group: { 'user:name': 'Brad' } },
+          { value: 824, group: { 'user:name': 'Pit' } },
+        ]);
+
+        const caller = factories.caller.build();
+        const filter = factories.filter.build({
+          conditionTree: factories.conditionTreeLeaf.build({
+            field: 'card:id',
+            operator: 'Equal',
+            value: 1,
+          }),
+        });
+        const aggregation = new Aggregation({
+          operation: 'Sum',
+          field: 'amountInEur',
+          groups: [{ field: 'user:name' }],
+        });
+
+        const results = await decoratedTransactions.aggregate(caller, filter, aggregation, 1);
+
+        const expectedFilter = factories.filter.build({
+          conditionTree: factories.conditionTreeLeaf.build({
+            field: 'card_id',
+            operator: 'Equal',
+            value: 1,
+          }),
+        });
+
+        expect(spy).toHaveBeenCalledWith(caller, expectedFilter, aggregation, 1);
+        expect(results).toStrictEqual([
+          { value: 1824.11, group: { 'user:name': 'Brad' } },
+          { value: 824, group: { 'user:name': 'Pit' } },
+        ]);
+      });
+    });
+
+    describe('when filter on foreign field', () => {
+      test('it should join', async () => {
+        const spy = jest.spyOn(transactions, 'aggregate');
+        spy.mockResolvedValue([
+          { value: 1824.11, group: { 'user:name': 'Brad' } },
+          { value: 824, group: { 'user:name': 'Pit' } },
+        ]);
+
+        const caller = factories.caller.build();
+        const filter = factories.filter.build({
+          conditionTree: factories.conditionTreeLeaf.build({
+            field: 'card:type',
+            operator: 'Equal',
+            value: 'Visa',
+          }),
+        });
+        const aggregation = new Aggregation({
+          operation: 'Sum',
+          field: 'amountInEur',
+          groups: [{ field: 'user:name' }],
+        });
+
+        const results = await decoratedTransactions.aggregate(caller, filter, aggregation, 1);
+
+        expect(spy).toHaveBeenCalledWith(caller, filter, aggregation, 1);
+        expect(results).toStrictEqual([
+          { value: 1824.11, group: { 'user:name': 'Brad' } },
+          { value: 824, group: { 'user:name': 'Pit' } },
+        ]);
+      });
+    });
+  });
+});