
L'ETL
ce qu'il vous manquait
pour intégrer vos clients
Lyon
CPE - 16/05/2025
📍

L'ETL
ce qu'il vous manquait
pour intégrer vos clients
Lyon
CPE - 16/05/2025
📍
Qui suis-je ?

Nicolas Jourdan
Lead Developer
Symfony
@NicolasJourdan_
NicolasJourdan
nicolas-jourdan.medium.com



L'ETL
ce qu'il vous manquait
pour intégrer vos clients
@afup@NicolasJourdan_Contexte
@afup@NicolasJourdan_Contexte
Mandarine🍊
@afup@NicolasJourdan_Contexte
Mandarine🍊

🍊

🍍

🍇

🍍

🍇


🍍

🍇


@afup@NicolasJourdan_Contexte

Nom
Taille
Capacité
SKU
Couleur
Tags
Mandarine🍊
@afup@NicolasJourdan_Contexte

[
{
"name": "YouPhone 16 Pro Max",
"size": 6.7,
"capacity": 512,
"sku": "YOUPHONE_16_PRO_MAX_ZXC",
"color": "black",
"tags": [
"premium",
"5G",
"waterproof"
]
}
]POST /api/phones
Mandarine🍊
@afup@NicolasJourdan_Contexte
[
{
"name": "YouPhone 16 Pro Max",
"size": 6.7,
"capacity": 512,
"sku": "YOUPHONE_16_PRO_MAX_ZXC",
"color": "black",
"tags": [
"premium",
"5G",
"waterproof"
]
}
]


Mandarine🍊
POST
🍊
🍊
🍊
🍐
🍋
🍇






Contexte
SUPER👌
@afup@NicolasJourdan_Contexte
MAIS
@afup@NicolasJourdan_Contexte
🚰
@afup@NicolasJourdan_Contexte
Poire🍐
@afup@NicolasJourdan_Contexte

🍐


Poire🍐
@afup@NicolasJourdan_Contexte

🍐


Poire🍐


🍐


🍐

🍍

🍇

🍐

🍐
@afup@NicolasJourdan_Contexte

Poire🍐
Nom
Couleur
Taille
SKU
Famille
Capacité
Résistance
Connectivité
Prix
@afup@NicolasJourdan_Contexte
Poire🍐



🍐

[
{
"nom": "YouPhone 16 Pro Max",
"taille": 6.7,
"capacité": 512,
"code_produit": "YOUPHONE_16_PRO_MAX_ZXC",
"couleur": "black",
"connectivité": "5G",
"prix": 1499.90,
"famille": "pro",
"résistance": "IP68"
}
]
GET
🍐
🍊
🍍
🍇



@afup@NicolasJourdan_Contexte

Poire🍐
[
{
"nom": "YouPhone 16 Pro Max",
"taille": 6.7,
"capacité": 512,
"code_produit": "YOUPHONE_16_PRO_MAX_ZXC",
"couleur": "black",
"connectivité": "5G",
"prix": 1499.90,
"famille": "pro",
"résistance": "IP68"
}
]GET /api/telephones
@afup@NicolasJourdan_Contexte
Poire🍐
[
{
"nom": "YouPhone 16 Pro Max",
"taille": 6.7,
"capacité": 512,
"code_produit": "YOUPHONE_16_PRO_MAX_ZXC",
"couleur": "black",
"connectivité": "5G",
"prix": 1499.90,
"famille": "pro",
"résistance": "IP68"
}
]GET /api/telephones
[
{
"name": "YouPhone 16 Pro Max",
"size": 6.7,
"capacity": 512,
"sku": "YOUPHONE_16_PRO_MAX_ZXC",
"color": "black",
"tags": [
"premium",
"5G",
"waterproof"
]
}
]POST /api/phones
Mandarine🍊
@afup@NicolasJourdan_
Délai serré
Récupération des données
Contraintes
Développements spécifiques (tags)
Mapping des champs
Faible volumétrie
@afup@NicolasJourdan_ETL
@afup@NicolasJourdan_ETL
Extract Transform Load
| 🧃 | 1 L |
- Base de données
- API
- Fichier (CSV, XML, etc.)

| 🍇 | 1,5 kg |
| 🍌 | 1 kg |
| 🍇 | 1,5 kg |
| 🧃 | 1 L |
| 🍇 | 1,5 kg |
| 🍌 | 1 kg |
- Base de données
- API
- Fichier (CSV, XML, etc.)
🍐Poire/Mandarine🍊
Extract Transform Load

[
{
"nom": "YouPhone 16 Pro Max",
"taille": 6.7,
"capacité": 512,
"code_produit": "YOUPHONE_16_PRO_MAX_ZXC",
"couleur": "black",
"connectivité": "5G",
"prix": 1499.90,
"famille": "pro",
"résistance": "IP68"
}
]GET
🍐
[
{
"name": "YouPhone 16 Pro Max",
"size": 6.7,
"capacity": 512,
"sku": "YOUPHONE_16_PRO_MAX_ZXC",
"color": "black",
"tags": [
"premium",
"5G",
"waterproof"
]
}
]POST

🍊@afup@NicolasJourdan_ETL
Outils existants
Projet interne

AWS Glue

Talend Open Studio
Apache Airflow


@afup@NicolasJourdan_ETL
Outils existants
Connecteurs
"Un connecteur est un module préconfiguré qui permet à un outil ETL de communiquer avec une source ou une destination."
@afup@NicolasJourdan_PostgreSQL
MongoDB
MySQL
S3
HubSpot
SalesForce

ElasticSearch

Google Analytics
ETL

AWS Glue
ETL
Outils existants
Pas de connecteur ?
@afup@NicolasJourdan_

Python
Scala
ETL

AWS Glue
ETL
Comparaison
🍐Poire/Mandarine🍊
@afup@NicolasJourdan_ETL
| Critère | Outils existants | Projet interne |
|---|---|---|
| Temps de développement | Long si besoin de créer un connecteur (Python/Scala) | Rapide |
🍐Poire/Mandarine🍊
@afup@NicolasJourdan_ETL
| Critère | Outils existants | Projet interne |
|---|---|---|
| Temps de développement | Long si besoin de créer un connecteur (Python/Scala) | Rapide |
| Connectivité | Limitée | Facile |
🍐Poire/Mandarine🍊
@afup@NicolasJourdan_ETL
| Critère | Outils existants | Projet interne |
|---|---|---|
| Temps de développement | Long si besoin de créer un connecteur (Python/Scala) | Rapide |
| Connectivité | Limitée | Facile |
| Flexibilité (format, etc.) | Limitée | Totale |
🍐Poire/Mandarine🍊
@afup@NicolasJourdan_ETL
| Critère | Outils existants | Projet interne |
|---|---|---|
| Temps de développement | Long si besoin de créer un connecteur (Python/Scala) | Rapide |
| Connectivité | Limitée | Facile |
| Flexibilité (format, etc.) | Limitée | Totale |
| Évolutivité | Limitée | Facile |
🍐Poire/Mandarine🍊
@afup@NicolasJourdan_ETL
| Critère | Outils existants | Projet interne |
|---|---|---|
| Temps de développement | Long si besoin de créer un connecteur (Python/Scala) | Rapide |
| Connectivité | Limitée | Facile |
| Flexibilité (format, etc.) | Limitée | Totale |
| Évolutivité | Limitée | Facile |
| Scalabilité | Optimisée | Limitée |
🍐Poire/Mandarine🍊
@afup@NicolasJourdan_ETL
| Critère | Outils existants | Projet interne |
|---|---|---|
| Temps de développement | Long si besoin de créer un connecteur (Python/Scala) | Rapide |
| Connectivité | Limitée | Facile |
| Flexibilité (format, etc.) | Limitée | Totale |
| Évolutivité | Limitée | Facile |
| Scalabilité | Optimisée | Limitée |
| Coût | Potentiellement élevé | Faible |
🍐Poire/Mandarine🍊
@afup@NicolasJourdan_ETL
| Critère | Outils existants | Projet interne |
|---|---|---|
| Temps de développement | Long si besoin de créer un connecteur (Python/Scala) | Rapide |
| Connectivité | Limitée | Facile |
| Flexibilité (format, etc.) | Limitée | Totale |
| Évolutivité | Limitée | Facile |
| Scalabilité | Optimisée | Limitée |
| Coût | Potentiellement élevé | Faible |
| Performance | Optimisée | Limitée |
🍐Poire/Mandarine🍊
@afup@NicolasJourdan_ETL
| Critère | Outils existants | Projet interne |
|---|---|---|
| Temps de développement | Long si besoin de créer un connecteur (Python/Scala) | Rapide |
| Connectivité | Limitée | Facile |
| Flexibilité (format, etc.) | Limitée | Totale |
| Évolutivité | Limitée | Facile |
| Scalabilité | Optimisée | Limitée |
| Coût | Potentiellement élevé | Faible |
| Performance | Optimisée | Limitée |
| Monitoring | Intégré | À développer manuellement |
🍐Poire/Mandarine🍊
@afup@NicolasJourdan_ETL
| Critère | Outils existants | Projet interne |
|---|---|---|
| Temps de développement | Long si besoin de créer un connecteur (Python/Scala) | Rapide |
| Connectivité | Limitée | Facile |
| Flexibilité (format, etc.) | Limitée | Totale |
| Évolutivité | Limitée | Facile |
| Scalabilité | Optimisée | Limitée |
| Coût | Potentiellement élevé | Faible |
| Performance | Optimisée | Limitée |
| Monitoring | Intégré | À développer manuellement |
| Sécurité | Intégrée | À gérer manuellement |
🍐Poire/Mandarine🍊
@afup@NicolasJourdan_ETL
| Critère | Outils existants | Projet interne |
|---|---|---|
| Temps de développement | Rapide | Rapide |
| Connectivité | Facile | Facile |
| Flexibilité (format, etc.) | Totale | Totale |
| Évolutivité | Facile | Facile |
| Scalabilité | Optimisée | Limitée |
| Coût | Potentiellement élevé | Faible |
| Performance | Optimisée | Limitée |
| Monitoring | Intégré | À développer manuellement |
| Sécurité | Intégrée | À gérer manuellement |
Avec un connecteur existant
@afup@NicolasJourdan_ETL


Projet interne
@afup@NicolasJourdan_@afup@NicolasJourdan_ETL
🍐


🍊


🍐
🍊


@afup@NicolasJourdan_

🍐
🍊Extract
Transform
Load
Extract
@afup@NicolasJourdan_Extract
<?php
readonly class ProductFetcher
{
//...
public function fetch(): array
{
$response = $this->client->request('GET', $this->apiUrl, [
'auth_bearer' => $this->apiKey,
]);
if ($response->getStatusCode() !== 200) {
throw new \RuntimeException('Failed to fetch products');
}
return $response->toArray();
}
}
Extract
$pearProducts = [
[
"nom" => "YouPhone 16 Pro Max",
"taille" => 6.7,
"capacité" => 512,
"code_produit" => "YOUPHONE_16_PRO_MAX_ZXC",
"couleur" => "black",
"connectivité" => "5G",
"prix" => 1499.90,
"famille" => "pro",
"résistance" => "IP68"
]
];
@afup@NicolasJourdan_@afup@NicolasJourdan_

🍐
🍊Extract
Transform
Load
$pearProducts = [
[
"nom" => "YouPhone 16 Pro Max",
"taille" => 6.7,
"capacité" => 512,
"code_produit" => "YOUPHONE_16_PRO_MAX_ZXC",
"couleur" => "black",
"connectivité" => "5G",
"prix" => 1499.90,
"famille" => "pro",
"résistance" => "IP68"
]
];
Transform
@afup@NicolasJourdan_Transform
<?php
readonly class ProductMapper
{
public function map(array $pearProducts): array
{
$mappedProducts = [];
foreach ($pearProducts as $pearProduct) {
$mappedProducts[] = [
"name" => $pearProduct["nom"],
"size" => $pearProduct["taille"],
// ...
'tags' => $this->resolveTags($pearProduct),
];
}
return $mappedProducts;
}
// ...
}
Transform
<?php
readonly class ProductMapper
{
//...
private function resolveTags(array $pearProduct): array
{
return \array_filter([
$pearProduct['connectivité'] ?? null, // 5G, 4G, 3G, etc.
//...
]);
}
}
@afup@NicolasJourdan_Transform
<?php
readonly class ProductMapper
{
//...
private function resolveTags(array $pearProduct): array
{
return \array_filter([
//...
(
isset($pearProduct['famille']) && $pearProduct['famille'] === 'pro'
&& isset($pearProduct['prix']) && $pearProduct['prix'] > 1000
) ? 'premium' : null,
//...
]);
}
}
@afup@NicolasJourdan_Transform
<?php
readonly class ProductMapper
{
//...
private function resolveTags(array $pearProduct): array
{
return \array_filter([
//...
(
isset($pearProduct['résistance'])
&& $this->extractResistance($pearProduct['résistance']) >= 67
) ? 'waterproof' : null,
]);
}
}
@afup@NicolasJourdan_Transform
$mandarineProducts = [
[
"name" => "YouPhone 16 Pro Max",
"size" => 6.7,
"capacity" => 512,
"sku" => "YOUPHONE_16_PRO_MAX_ZXC",
"color" => "black",
"tags" => [
"premium",
"5G",
"waterproof"
]
]
];
@afup@NicolasJourdan_Transform
$mandarineProducts = [
[
"name" => "YouPhone 16 Pro Max",
"size" => 6.7,
"capacity" => 512,
"sku" => "YOUPHONE_16_PRO_MAX_ZXC",
"color" => "black",
"tags" => [
"premium",
"5G",
"waterproof"
]
]
];
$pearProducts = [
[
"nom" => "YouPhone 16 Pro Max",
"taille" => 6.7,
"capacité" => 512,
"code_produit" => "YOUPHONE_16_PRO_MAX_ZXC",
"couleur" => "black",
"connectivité" => "5G",
"prix" => 1499.90,
"famille" => "pro",
"résistance" => "IP68"
]
];@afup@NicolasJourdan_@afup@NicolasJourdan_

🍐
🍊Extract
Transform
Load
$pearProducts = [
[
"nom" => "YouPhone 16 Pro Max",
"taille" => 6.7,
"capacité" => 512,
"code_produit" => "YOUPHONE_16_PRO_MAX_ZXC",
"couleur" => "black",
"connectivité" => "5G",
"prix" => 1499.90,
"famille" => "pro",
"résistance" => "IP68"
]
];
$mandarineProducts = [
[
"name" => "YouPhone 16 Pro Max",
"size" => 6.7,
"capacity" => 512,
"sku" => "YOUPHONE_16_PRO_MAX_ZXC",
"color" => "black",
"tags" => [
"premium",
"5G",
"waterproof"
]
]
];
Load
@afup@NicolasJourdan_Load
<?php
readonly class ProductLoader
{
//...
public function load(array $mandarineProducts): void
{
$response = $this->client->request('POST', $this->apiUrl, [
'auth_bearer' => $this->apiKey,
'json' => $mandarineProducts,
]);
if ($response->getStatusCode() !== 201) {
throw new \RuntimeException('Failed to load products');
}
}
}
@afup@NicolasJourdan_

🍐
🍊Extract
Transform
Load
$pearProducts = [
[
"nom" => "YouPhone 16 Pro Max",
"taille" => 6.7,
"capacité" => 512,
"code_produit" => "YOUPHONE_16_PRO_MAX_ZXC",
"couleur" => "black",
"connectivité" => "5G",
"prix" => 1499.90,
"famille" => "pro",
"résistance" => "IP68"
]
];
$mandarineProducts = [
[
"name" => "YouPhone 16 Pro Max",
"size" => 6.7,
"capacity" => 512,
"sku" => "YOUPHONE_16_PRO_MAX_ZXC",
"color" => "black",
"tags" => [
"premium",
"5G",
"waterproof"
]
]
];

Job
@afup@NicolasJourdan_Job
<?php
readonly class ProductJob
{
public function __construct(
private ProductFetcher $productFetcher,
private ProductMapper $productMapper,
private ProductLoader $productLoader,
) {
}
public function __invoke(): void
{
$products = $this->productFetcher->fetch();
$mappedProducts = $this->productMapper->map($products);
$this->productLoader->load($mappedProducts);
}
}@afup@NicolasJourdan_@afup@NicolasJourdan_ETL
🍐


🍊


🍐
🍊



Défis

@afup@NicolasJourdan_Défis
Système de logs
Nouveau flux en entrée / sortie
@afup@NicolasJourdan_Données manquantes
Données manquantes
@afup@NicolasJourdan_<?php
readonly class ProductMapper
{
private OptionsResolver $resolver;
public function __construct()
{
$this->resolver = new OptionsResolver();
$this->configureOptions($this->resolver);
}
//...
}
<?php
readonly class ProductMapper
{
//...
public function configureOptions(OptionsResolver $resolver): void
{
$resolver
->setRequired(['nom', 'taille', 'capacité', 'code_produit', 'couleur'])
->setDefined(['connectivité', 'famille', 'prix', 'résistance'])
->setAllowedTypes('nom', 'string')
->setAllowedTypes('taille', 'float')
->setAllowedTypes('capacité', 'int')
->setAllowedTypes('code_produit', 'string')
->setAllowedTypes('couleur', 'string')
//...
;
}
}
<?php
readonly class ProductMapper
{
//...
public function map(array $pearProducts): array
{
$mappedProducts = [];
foreach ($pearProducts as $pearProduct) {
$pearProduct = $this->resolver->resolve($pearProduct);
$mappedProducts[] = [
"name" => $pearProduct["nom"],
//...
];
}
return $mappedProducts;
}
//...
}
Système de logs
@afup@NicolasJourdan_<?php
use Psr\Log\LoggerInterface;
readonly class ProductFetcher
{
public function __construct(
private LoggerInterface $logger,
//...
) {
}
//...
}
<?php
readonly class ProductFetcher
{
//...
public function fetch(): array
{
$this->logger->info('Starting product fetch from external API', [
'url' => $this->apiUrl,
]);
//...
}
}
<?php
readonly class ProductFetcher
{
//...
public function fetch(): array
{
//...
try {
$response = $this->client->request('GET', $this->apiUrl, [
'auth_bearer' => $this->apiKey,
]);
} catch (\Throwable $e) {
$this->logger->error('API request failed', [
'exception' => $e,
'url' => $this->apiUrl,
]);
throw new \RuntimeException('API request failed');
}
//...
}
}
<?php
readonly class ProductFetcher
{
//...
public function fetch(): array
{
//...
$statusCode = $response->getStatusCode();
if ($statusCode !== 200) {
$this->logger->error('Product API returned unexpected status code', [
'status_code' => $statusCode,
]);
throw new \RuntimeException('Unexpected status code');
}
//...
}
}
<?php
readonly class ProductFetcher
{
//...
public function fetch(): array
{
//...
$products = $response->toArray();
$this->logger->info('Product data successfully fetched', [
'product_count' => \count($products),
]);
return $products;
}
}
<?php
readonly class ProductMapper
{
//...
public function map(array $pearProducts): array
{
$mappedProducts = [];
foreach ($pearProducts as $pearProduct) {
try {
$resolved = $this->resolver->resolve($pearProduct);
//...
} catch (ExceptionInterface $e) {
$this->logger->error('Failed to resolve product', [
'error' => $e->getMessage(),
'product' => $pearProduct,
]);
continue;
}
}
//...
}
//...
}
<?php
readonly class ProductLoader
{
//...
public function load(array $mandarineProducts): void
{
$this->logger->info('Starting product load to internal API', [
'url' => $this->apiUrl,
'product_count' => \count($mandarineProducts),
]);
//...
}
}
<?php
readonly class ProductLoader
{
//...
public function load(array $mandarineProducts): void
{
//...
try {
$response = $this->client->request('POST', $this->apiUrl, [
'auth_bearer' => $this->apiKey,
'json' => $mandarineProducts,
]);
} catch (\Throwable $e) {
$this->logger->error('API request failed during product load', [
'exception' => $e,
'url' => $this->apiUrl,
]);
throw new \RuntimeException('Failed to load products');
}
//...
}
}
<?php
readonly class ProductLoader
{
//...
public function load(array $mandarineProducts): void
{
//...
$statusCode = $response->getStatusCode();
if ($statusCode !== 201) {
$this->logger->error('Unexpected status code', [
'status_code' => $statusCode,
]);
throw new \RuntimeException('Failed to load products');
}
$this->logger->info('Product load completed successfully');
}
}
Nouveau flux en entrée / sortie
@afup@NicolasJourdan_<?php
interface ProductFetcherInterface
{
public function fetch(): array;
}
readonly class ProductFetcher implements ProductFetcherInterface {...}
Interfaces
@afup@NicolasJourdan_<?php
interface ProductMapperInterface
{
public function map(array $pearProducts): array;
}
readonly class ProductMapper implements ProductMapperInterface {...}
Interfaces
@afup@NicolasJourdan_<?php
interface ProductLoaderInterface
{
public function load(array $mandarineProducts): void;
}
readonly class ProductLoader implements ProductLoaderInterface {...}Interfaces
@afup@NicolasJourdan_Job
<?php
readonly class ProductJob
{
public function __construct(
private ProductFetcherInterface $productFetcher,
private ProductMapperInterface $productMapper,
private ProductLoaderInterface $productLoader,
) {
}
public function __invoke(): void
{
$products = $this->productFetcher->fetch();
$mappedProducts = $this->productMapper->map($products);
$this->productLoader->load($mappedProducts);
}
}@afup@NicolasJourdan_Défis
Performances
Volume de données
...
@afup@NicolasJourdan_@afupBonnes pratiques
et
pièges à éviter
@afup@NicolasJourdan_Bonnes pratiques et
pièges à éviter

Bien comprendre le besoin métier et les contraintes techniques
Volume ?
Charge ?
Performances ?
Flux ?
@afup@NicolasJourdan_Bonnes pratiques et
pièges à éviter

Bien comprendre le besoin métier et les contraintes techniques
Outil existant
Projet interne

Choisir la solution la plus adaptée
@afup@NicolasJourdan_Bonnes pratiques et
pièges à éviter

Bien comprendre le besoin métier et les contraintes techniques

Choisir la solution la plus adaptée

Documenter (mappings, schémas, flux, etc.)
@afup@NicolasJourdan_Bonnes pratiques et
pièges à éviter




Tests et validation des données
Gestion des erreurs et des cas limites
Logs et alerting
Flexibilité et évolutivité
@afup@NicolasJourdan_Merci !
@afup@NicolasJourdan_Feedback
L’ETL, ce qu’il vous manquait pour intégrer vos clients (AFUP Day 2025)
By Nicolas Jourdan
L’ETL, ce qu’il vous manquait pour intégrer vos clients (AFUP Day 2025)
- 277