Encapsulación

Avanzado
JavaScript
JavaScript
Actualizado: 16/05/2025

¡Desbloquea el curso completo!

IA
Ejercicios
Certificado
Entrar

Fundamentos de la encapsulación: Ocultamiento de datos y control de acceso en JavaScript

La encapsulación es uno de los pilares fundamentales de la programación orientada a objetos que aprendimos junto con la herencia y el polimorfismo. Este principio permite ocultar los detalles internos de implementación de un objeto y exponer solo aquellas partes que son necesarias para su uso.

¿Qué es la encapsulación?

La encapsulación se basa en dos conceptos principales:

  1. Ocultamiento de datos: Restringir el acceso directo a ciertos componentes del objeto
  2. Control de acceso: Proporcionar interfaces controladas para interactuar con esos datos

El objetivo principal es proteger la integridad de los datos internos de un objeto, evitando modificaciones accidentales o incorrectas desde el exterior.

Convenciones de nomenclatura

Históricamente, JavaScript ha utilizado convenciones de nomenclatura para indicar que ciertas propiedades deberían tratarse como privadas:

class BankAccount {
  constructor(owner) {
    this.owner = owner;
    this._balance = 0; // El prefijo _ indica "privado por convención"
  }
  
  deposit(amount) {
    if (amount > 0) {
      this._balance += amount;
      return true;
    }
    return false;
  }
  
  getBalance() {
    return this._balance;
  }
}

En este ejemplo, _balance utiliza el prefijo _ para indicar que es una propiedad que no debería accederse directamente desde fuera de la clase. Sin embargo, esta es solo una convención y no impone ninguna restricción real:

const account = new BankAccount("Alice");
account.deposit(100);
console.log(account.getBalance()); // 100 (acceso correcto)
account._balance = 1000000; // Funciona, pero rompe la encapsulación

Esta técnica es muy común en código JavaScript y es importante reconocerla, aunque no proporciona una verdadera encapsulación.

Closures para encapsulación

Antes de la introducción de clases en ES6, los closures eran el mecanismo principal para implementar encapsulación real en JavaScript:

function createCounter() {
  let count = 0; // Variable privada
  
  return {
    increment() {
      count++;
    },
    decrement() {
      count--;
    },
    getValue() {
      return count;
    }
  };
}

const counter = createCounter();
counter.increment();
console.log(counter.getValue()); // 1
console.log(counter.count); // undefined - no hay acceso directo

En este patrón, la variable count está encapsulada dentro del closure y solo es accesible a través de los métodos proporcionados. Este enfoque ofrece una verdadera encapsulación, ya que no hay forma de acceder directamente a la variable privada.

Getters y setters

Los getters y setters proporcionan una forma más elegante de controlar el acceso a las propiedades:

class Temperature {
  constructor(celsius) {
    this._celsius = celsius;
  }
  
  get celsius() {
    return this._celsius;
  }
  
  set celsius(value) {
    if (value < -273.15) {
      throw new Error("Temperature below absolute zero is not possible");
    }
    this._celsius = value;
  }
  
  get fahrenheit() {
    return this._celsius * 9/5 + 32;
  }
  
  set fahrenheit(value) {
    this.celsius = (value - 32) * 5/9;
  }
}

Los getters y setters permiten:

  • Validar datos antes de asignarlos
  • Calcular valores derivados bajo demanda
  • Mantener la consistencia entre propiedades relacionadas
  • Ocultar la implementación interna
const temp = new Temperature(25);
console.log(temp.celsius); // 25
console.log(temp.fahrenheit); // 77

temp.celsius = 30;
console.log(temp.fahrenheit); // 86

// Validación en acción
try {
  temp.celsius = -300; // Error: Temperature below absolute zero
} catch (e) {
  console.log(e.message);
}

Este enfoque combina de manera efectiva la sintaxis de clase que aprendimos anteriormente con mecanismos para controlar el acceso a datos.

Símbolos para propiedades semi-privadas

Los símbolos introducidos en ES6 ofrecen otra forma de implementar propiedades semi-privadas:

const passwordSymbol = Symbol('password');

class User {
  constructor(username, password) {
    this.username = username;
    this[passwordSymbol] = password;
  }
  
  validatePassword(input) {
    return this[passwordSymbol] === input;
  }
}

const user = new User("admin", "1234");
console.log(user.validatePassword("1234")); // true
console.log(user[passwordSymbol]); // Accesible si se conoce el símbolo
console.log(Object.keys(user)); // ["username"] - el símbolo no aparece

Las propiedades basadas en símbolos:

  • No aparecen en iteraciones estándar como Object.keys()
  • No son accesibles directamente sin conocer el símbolo
  • Proporcionan un nivel de privacidad práctica aunque no absoluta

La encapsulación en JavaScript ha evolucionado desde simples convenciones hasta mecanismos más robustos. Aunque ninguna de estas técnicas tradicionales ofrece una encapsulación perfecta, proporcionan diferentes niveles de protección y control de acceso que son útiles en distintos contextos de desarrollo.

¿Te está gustando esta lección?

Inicia sesión para no perder tu progreso y accede a miles de tutoriales, ejercicios prácticos y nuestro asistente de IA.

Progreso guardado
Asistente IA
Ejercicios
Iniciar sesión gratis

Más de 25.000 desarrolladores ya confían en CertiDevs

Técnicas modernas para la implementación de encapsulación: Campos privados y patrones de módulo

JavaScript ha evolucionado significativamente en los últimos años, introduciendo características nativas que permiten implementar una encapsulación más robusta y elegante. Las técnicas modernas ofrecen soluciones que van más allá de las convenciones y patrones tradicionales, proporcionando mecanismos de encapsulación con mayor integridad y claridad sintáctica.

Campos privados con # (ECMAScript 2022)

La adición más importante para la encapsulación en JavaScript moderno es la implementación de campos privados utilizando el prefijo #. Esta característica proporciona una verdadera privacidad a nivel de lenguaje:

class BankAccount {
  #balance;
  #transactionHistory;
  
  constructor(initialBalance = 0) {
    this.#balance = initialBalance;
    this.#transactionHistory = [];
    if (initialBalance > 0) {
      this.#addTransaction("initial deposit", initialBalance);
    }
  }
  
  deposit(amount) {
    if (amount <= 0) {
      throw new Error("Deposit amount must be positive");
    }
    
    this.#balance += amount;
    this.#addTransaction("deposit", amount);
    return this.#balance;
  }
  
  withdraw(amount) {
    if (amount <= 0) {
      throw new Error("Withdrawal amount must be positive");
    }
    
    if (amount > this.#balance) {
      throw new Error("Insufficient funds");
    }
    
    this.#balance -= amount;
    this.#addTransaction("withdrawal", amount);
    return this.#balance;
  }
  
  #addTransaction(type, amount) {
    this.#transactionHistory.push({
      type,
      amount,
      date: new Date()
    });
  }
  
  getBalance() {
    return this.#balance;
  }
  
  getTransactionHistory() {
    // Devolvemos una copia para evitar modificaciones externas
    return [...this.#transactionHistory];
  }
}

A diferencia de las convenciones con guion bajo, los campos privados con # están estrictamente encapsulados:

const account = new BankAccount(1000);
console.log(account.getBalance()); // 1000
account.deposit(500);
account.withdraw(200);
console.log(account.getBalance()); // 1300

// Intentos de acceso directo generan errores
try {
  console.log(account.#balance); // SyntaxError
} catch (e) {
  console.log("No se puede acceder a campos privados desde fuera");
}

Los campos privados con # ofrecen varias ventajas clave:

  • Son verdaderamente privados y no solo por convención
  • Generan un error de sintaxis si se intenta acceder desde fuera
  • No pueden ser descubiertos mediante técnicas de introspección como Object.keys()
  • Funcionan con métodos, getters y setters privados

Aunque los campos privados con # son una característica relativamente reciente, proporcionan el mecanismo de encapsulación más robusto en JavaScript actual. Su soporte en navegadores modernos es bueno, pero para entornos más antiguos, es recomendable utilizar las técnicas alternativas que hemos visto.

Patrón de módulo moderno con ESM

El sistema de módulos nativo de JavaScript (ESM) proporciona otra capa de encapsulación a nivel de archivo:

// userService.js
const API_KEY = "secret_api_key"; // Variable privada al módulo

function validateEmail(email) {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}

export function createUser(userData) {
  if (!validateEmail(userData.email)) {
    throw new Error("Invalid email format");
  }
  
  // Lógica que usa API_KEY internamente
  return fetch("https://api.example.com/users", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${API_KEY}`,
      "Content-Type": "application/json"
    },
    body: JSON.stringify(userData)
  }).then(res => res.json());
}

export function getUser(id) {
  // Implementación que usa API_KEY
}

// No exportamos validateEmail ni API_KEY

En otro archivo, solo podemos acceder a lo que se exporta explícitamente:

// app.js
import { createUser, getUser } from './userService.js';

// Podemos usar las funciones exportadas
createUser({ name: "Alice", email: "alice@example.com" });

// Pero no tenemos acceso a las partes privadas
// API_KEY y validateEmail no son accesibles aquí

Este patrón permite:

  • Encapsular variables y funciones a nivel de módulo
  • Exponer solo una API pública bien definida
  • Mantener la seguridad de datos sensibles como claves API
  • Organizar el código en unidades cohesivas

Patrón de módulo con IIFE (Immediately Invoked Function Expression)

Este patrón, que utiliza closures, ha sido una técnica estándar para encapsulación en JavaScript durante muchos años:

const calculator = (function() {
  // Variables privadas
  let result = 0;
  
  // Funciones privadas
  function validate(n) {
    if (typeof n !== 'number') {
      throw new Error('Only numbers are allowed');
    }
  }
  
  // API pública
  return {
    add(n) {
      validate(n);
      result += n;
      return this;
    },
    
    subtract(n) {
      validate(n);
      result -= n;
      return this;
    },
    
    multiply(n) {
      validate(n);
      result *= n;
      return this;
    },
    
    divide(n) {
      validate(n);
      if (n === 0) {
        throw new Error('Cannot divide by zero');
      }
      result /= n;
      return this;
    },
    
    getResult() {
      return result;
    },
    
    clear() {
      result = 0;
      return this;
    }
  };
})();

// Uso
calculator.add(5).multiply(2).subtract(3).divide(2);
console.log(calculator.getResult()); // 3.5

Este patrón crea un ámbito privado donde podemos definir variables y funciones que no son accesibles desde el exterior, exponiendo solo una API pública bien definida.

WeakMaps para datos privados (enfoque alternativo)

Antes de los campos privados nativos, los WeakMaps ofrecían una solución elegante para almacenar datos privados:

// Una alternativa para entornos que no soportan campos privados
const privateData = new WeakMap();

class Person {
  constructor(name, age) {
    privateData.set(this, {
      name,
      age
    });
  }

  getName() {
    return privateData.get(this).name;
  }
  
  getAge() {
    return privateData.get(this).age;
  }
  
  setAge(age) {
    if (age < 0 || age > 120) {
      throw new Error('Invalid age');
    }
    privateData.get(this).age = age;
  }
  
  celebrateBirthday() {
    const data = privateData.get(this);
    data.age += 1;
    return `Happy ${data.age}th birthday, ${data.name}!`;
  }
}

const person = new Person("Alice", 30);
console.log(person.getName()); // "Alice"
console.log(person.getAge()); // 30
console.log(person.celebrateBirthday()); // "Happy 31th birthday, Alice!"

Esta técnica sigue siendo útil en ciertos contextos, especialmente cuando se necesita compatibilidad con navegadores antiguos o cuando se trabaja con objetos que no son instancias de clases.

Las técnicas modernas de encapsulación en JavaScript proporcionan mecanismos robustos para proteger la integridad de los datos y crear APIs limpias y bien definidas. Los campos privados con # y el sistema de módulos ESM representan un avance significativo en la madurez del lenguaje para la programación orientada a objetos.

Módulos ES (ECMAScript Modules)

El sistema de módulos nativo de JavaScript proporciona otra capa de encapsulación a nivel de archivo:

// userService.js
// Variables privadas al módulo
const API_KEY = "secret_api_key";
const BASE_URL = "https://api.example.com";

// Función privada
function validateEmail(email) {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}

// Funciones exportadas (API pública)
export function createUser(userData) {
  if (!validateEmail(userData.email)) {
    throw new Error("Invalid email format");
  }
  
  // Uso interno de variables privadas
  return fetch(`${BASE_URL}/users`, {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${API_KEY}`,
      "Content-Type": "application/json"
    },
    body: JSON.stringify(userData)
  }).then(res => res.json());
}

export function getUser(id) {
  return fetch(`${BASE_URL}/users/${id}`, {
    headers: {
      "Authorization": `Bearer ${API_KEY}`
    }
  }).then(res => res.json());
}

En otro archivo, solo podemos acceder a lo que se exporta explícitamente:

// app.js
import { createUser, getUser } from './userService.js';

// Podemos usar las funciones exportadas
createUser({ name: "Alice", email: "alice@example.com" });

// Pero no tenemos acceso a las partes privadas
// API_KEY, BASE_URL y validateEmail no son accesibles aquí

Este patrón permite:

  • Encapsular variables y funciones a nivel de módulo
  • Exponer solo una API pública bien definida
  • Mantener la seguridad de datos sensibles
  • Organizar el código en unidades cohesivas

Aplicaciones prácticas de la encapsulación: Integrando encapsulación en nuestros diseños orientados a objetos

La encapsulación no es solo un concepto teórico, sino una herramienta práctica para crear código más mantenible, seguro y flexible. Veamos cómo integrar la encapsulación con los conceptos de POO que hemos aprendido.

Encapsulación e invariantes de clase

La encapsulación nos permite mantener invariantes de clase - condiciones que siempre deben ser verdaderas para un objeto. Por ejemplo, un círculo siempre debe tener un radio positivo:

class Circle {
  #radius;
  
  constructor(radius) {
    this.setRadius(radius);
  }
  
  // Getter
  getRadius() {
    return this.#radius;
  }
  
  // Setter con validación
  setRadius(value) {
    if (typeof value !== 'number' || value <= 0) {
      throw new Error('Radius must be a positive number');
    }
    this.#radius = value;
  }
  
  // Métodos que dependen del estado interno
  getArea() {
    return Math.PI * this.#radius * this.#radius;
  }
  
  getCircumference() {
    return 2 * Math.PI * this.#radius;
  }
}

const circle = new Circle(5);
console.log(circle.getArea()); // ~78.54
circle.setRadius(10);
console.log(circle.getCircumference()); // ~62.83

try {
  circle.setRadius(-5); // Error: Radius must be a positive number
} catch (e) {
  console.log(e.message);
}

La encapsulación del radio permite que la clase mantenga su invariante (radio positivo) y evita que el objeto llegue a un estado inválido.

Encapsulación en herencia

La encapsulación funciona de manera interesante con la herencia. Las clases derivadas no pueden acceder a los campos privados de la clase base:

class Shape {
  #color;
  
  constructor(color) {
    this.#color = color;
  }
  
  getColor() {
    return this.#color;
  }
  
  setColor(color) {
    this.#color = color;
  }
  
  // Método protegido (por convención) que las subclases deberían implementar
  _calculateArea() {
    throw new Error('_calculateArea() must be implemented by subclasses');
  }
  
  // Método público que utiliza el método protegido
  getArea() {
    return this._calculateArea();
  }
}

class Rectangle extends Shape {
  #width;
  #height;
  
  constructor(color, width, height) {
    super(color);
    this.#width = width;
    this.#height = height;
  }
  
  // Implementación del método protegido
  _calculateArea() {
    return this.#width * this.#height;
  }
  
  // Getters y setters específicos
  getWidth() { return this.#width; }
  setWidth(width) { this.#width = width; }
  getHeight() { return this.#height; }
  setHeight(height) { this.#height = height; }
}

const rect = new Rectangle('red', 5, 3);
console.log(rect.getColor()); // 'red'
console.log(rect.getArea()); // 15

En este ejemplo vemos:

  • La clase base Shape tiene un campo privado #color y un método protegido (por convención) _calculateArea()
  • La clase derivada Rectangle implementa el método protegido y añade sus propios campos privados
  • La herencia funciona correctamente mientras cada clase encapsula sus propios detalles internos

Encapsulación para modelos de dominio

La encapsulación es especialmente útil para implementar modelos de dominio que encapsulan reglas de negocio:

class ShoppingCart {
  #items = [];
  #taxRate = 0.21; // 21% de IVA
  
  addItem(product, quantity = 1) {
    if (quantity <= 0) {
      throw new Error('Quantity must be positive');
    }
    
    const existingItem = this.#items.find(item => item.product.id === product.id);
    
    if (existingItem) {
      existingItem.quantity += quantity;
    } else {
      this.#items.push({ product, quantity });
    }
  }
  
  removeItem(productId) {
    const index = this.#items.findIndex(item => item.product.id === productId);
    if (index !== -1) {
      this.#items.splice(index, 1);
    }
  }
  
  updateQuantity(productId, quantity) {
    if (quantity <= 0) {
      return this.removeItem(productId);
    }
    
    const item = this.#items.find(item => item.product.id === productId);
    if (item) {
      item.quantity = quantity;
    }
  }
  
  getItems() {
    // Devolvemos una copia para prevenir modificaciones directas
    return [...this.#items];
  }
  
  getSubtotal() {
    return this.#items.reduce(
      (sum, item) => sum + (item.product.price * item.quantity), 
      0
    );
  }
  
  getTaxAmount() {
    return this.getSubtotal() * this.#taxRate;
  }
  
  getTotal() {
    return this.getSubtotal() + this.getTaxAmount();
  }
  
  getItemCount() {
    return this.#items.reduce((count, item) => count + item.quantity, 0);
  }
}

// Uso
const cart = new ShoppingCart();
cart.addItem({ id: 1, name: 'Laptop', price: 999.99 });
cart.addItem({ id: 2, name: 'Mouse', price: 29.99 }, 2);

console.log(`Items in cart: ${cart.getItemCount()}`);
console.log(`Subtotal: ${cart.getSubtotal().toFixed(2)} €`);
console.log(`Taxes: ${cart.getTaxAmount().toFixed(2)} €`);
console.log(`Total: ${cart.getTotal().toFixed(2)} €`);

Esta implementación encapsula:

  • La estructura interna de almacenamiento de items (#items)
  • La tasa de impuesto (#taxRate)
  • Las reglas de negocio (como el manejo de cantidades no válidas)
  • Los cálculos para subtotal, impuestos y total

Encapsulación para objetos de valor inmutables

La encapsulación combinada con inmutabilidad nos permite crear objetos de valor robustos:

class Money {
  #amount;
  #currency;
  
  constructor(amount, currency) {
    if (typeof amount !== 'number' || isNaN(amount)) {
      throw new TypeError('Amount must be a number');
    }
    
    if (typeof currency !== 'string' || currency.length !== 3) {
      throw new TypeError('Currency must be a 3-letter ISO code');
    }
    
    this.#amount = amount;
    this.#currency = currency.toUpperCase();
    
    // Congelar la instancia para prevenir la adición de propiedades
    Object.freeze(this);
  }
  
  get amount() { return this.#amount; }
  get currency() { return this.#currency; }
  
  // Operaciones que devuelven nuevos objetos en lugar de modificar el actual
  add(other) {
    if (!(other instanceof Money) || other.currency !== this.#currency) {
      throw new Error('Cannot add different currencies');
    }
    
    return new Money(this.#amount + other.amount, this.#currency);
  }
  
  subtract(other) {
    if (!(other instanceof Money) || other.currency !== this.#currency) {
      throw new Error('Cannot subtract different currencies');
    }
    
    return new Money(this.#amount - other.amount, this.#currency);
  }
  
  multiply(factor) {
    return new Money(this.#amount * factor, this.#currency);
  }
  
  toString() {
    return `${this.#amount.toFixed(2)} ${this.#currency}`;
  }
}

// Uso
const price = new Money(19.99, 'EUR');
const taxAmount = price.multiply(0.21);
const total = price.add(taxAmount);

console.log(`Price: ${price}`); // "19.99 EUR"
console.log(`Tax: ${taxAmount}`); // "4.20 EUR"
console.log(`Total: ${total}`); // "24.19 EUR"

Esta implementación combina encapsulación con inmutabilidad, lo que hace que los objetos Money sean:

  • Seguros para usar en entornos concurrentes
  • Fáciles de razonar sobre su estado
  • Menos propensos a errores debido a efectos secundarios

Conectando encapsulación con otros principios de POO

La encapsulación trabaja en conjunto con otros principios de POO:

// Clase base abstracta que define una interfaz
class PaymentMethod {
  // Métodos abstractos que las subclases deben implementar
  processPayment(amount) {
    throw new Error('Method not implemented');
  }
  
  refundPayment(transactionId) {
    throw new Error('Method not implemented');
  }
}

// Implementación concreta con detalles encapsulados
class CreditCardPayment extends PaymentMethod {
  #apiKey;
  #merchantId;
  #transactions = new Map();
  
  constructor(apiKey, merchantId) {
    super();
    this.#apiKey = apiKey;
    this.#merchantId = merchantId;
  }
  
  // Implementación del método de la interfaz
  processPayment(amount) {
    // En un caso real, esto llamaría a una API de pago
    console.log(`Processing credit card payment of ${amount.toFixed(2)} €`);
    
    const transactionId = this.#generateTransactionId();
    this.#transactions.set(transactionId, {
      amount,
      timestamp: new Date(),
      status: 'completed'
    });
    
    return {
      success: true,
      transactionId
    };
  }
  
  // Implementación del método de la interfaz
  refundPayment(transactionId) {
    if (!this.#transactions.has(transactionId)) {
      throw new Error(`Transaction ${transactionId} not found`);
    }
    
    const transaction = this.#transactions.get(transactionId);
    console.log(`Refunding ${transaction.amount.toFixed(2)} € to credit card`);
    
    transaction.status = 'refunded';
    return {
      success: true,
      refundedAmount: transaction.amount
    };
  }
  
  // Método privado
  #generateTransactionId() {
    return 'txn_' + Math.random().toString(36).substr(2, 9);
  }
}

// Otro implementador de la misma interfaz
class PayPalPayment extends PaymentMethod {
  #clientId;
  #clientSecret;
  #transactions = [];
  
  constructor(clientId, clientSecret) {
    super();
    this.#clientId = clientId;
    this.#clientSecret = clientSecret;
  }
  
  processPayment(amount) {
    console.log(`Processing PayPal payment of ${amount.toFixed(2)} €`);
    // Implementación específica...
    // ...
  }
  
  refundPayment(transactionId) {
    console.log(`Refunding PayPal transaction ${transactionId}`);
    // Implementación específica...
    // ...
  }
}

// Cliente que utiliza el polimorfismo sin conocer los detalles internos
function checkout(cart, paymentMethod) {
  const amount = cart.getTotal();
  return paymentMethod.processPayment(amount);
}

// Uso
const cart = new ShoppingCart();
cart.addItem({ id: 1, name: 'Laptop', price: 999.99 });

const ccPayment = new CreditCardPayment('sk_test_123', 'merch_456');
const paypalPayment = new PayPalPayment('client123', 'secret456');

// Mismo método, diferentes implementaciones encapsuladas
const ccResult = checkout(cart, ccPayment);
const ppResult = checkout(cart, paypalPayment);

Este ejemplo muestra cómo la encapsulación trabaja junto con:

  • Herencia: Las clases derivadas heredan la interfaz pero encapsulan su propia implementación
  • Polimorfismo: Diferentes implementaciones pueden usarse intercambiablemente
  • Abstracción: Los clientes solo ven la interfaz, no los detalles internos

Aprendizajes de esta lección

  • Comprender el concepto de encapsulación en la programación orientada a objetos.
  • Conocer las ventajas de aplicar la encapsulación en la organización y protección de la información interna de los objetos.
  • Aprender cómo implementar la encapsulación en JavaScript utilizando diferentes enfoques como IIFE, clausuras, objetos literales y clases con getters y setters.
  • Entender la diferencia entre propiedades y métodos públicos y privados, y cómo limitar el acceso a la información interna de los objetos.
  • Conocer los pros y contras de los enfoques de encapsulación en JavaScript y saber cuándo utilizar cada uno según las necesidades del proyecto.
  • Saber cómo la encapsulación mejora la modularidad y la mantenibilidad del código, y cómo evita la manipulación involuntaria de datos internos.

Completa JavaScript y certifícate

Únete a nuestra plataforma y accede a miles de tutoriales, ejercicios prácticos, proyectos reales y nuestro asistente de IA personalizado para acelerar tu aprendizaje.

Asistente IA

Resuelve dudas al instante

Ejercicios

Practica con proyectos reales

Certificados

Valida tus conocimientos

Más de 25.000 desarrolladores ya se han certificado con CertiDevs

⭐⭐⭐⭐⭐
4.9/5 valoración